diff --git a/Directory.Packages.props b/Directory.Packages.props index 00a88526627..d9841011f47 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,7 +40,6 @@ - @@ -147,7 +146,6 @@ - diff --git a/Orleans.slnx b/Orleans.slnx index b15f18cb065..e4047b5f194 100644 --- a/Orleans.slnx +++ b/Orleans.slnx @@ -70,7 +70,6 @@ - diff --git a/docs/site/src/content/docs/host/connection-middleware.md b/docs/site/src/content/docs/host/connection-middleware.md index d92f8b39eca..9caff701cd6 100644 --- a/docs/site/src/content/docs/host/connection-middleware.md +++ b/docs/site/src/content/docs/host/connection-middleware.md @@ -1,55 +1,55 @@ --- -title: Connection middleware -description: Plug custom logic, such as authentication or custom framing, into Orleans silo-to-silo and client-to-silo connections. -ms.date: 08/11/2026 +title: Message transport middleware +description: Decorate Orleans message transport connectors and listeners with cross-cutting connection behavior. +ms.date: 08/21/2026 ms.topic: how-to --- -# Connection middleware +# Message transport middleware -Orleans silo-to-silo and client-to-gateway connections are built from a chain of middleware, similar to an ASP.NET Core request pipeline. lets you insert custom logic, such as authentication, custom framing, or connection-level diagnostics, into that pipeline without reimplementing connection setup. +Orleans establishes silo-to-silo and client-to-gateway connections through message transport connectors and listeners. Transport middleware decorates those components before they create or accept a connection. -Orleans itself uses this abstraction for TLS. See [Secure Orleans connections with TLS](transport-layer-security.md) for the built-in TLS middleware. + applies to outbound connectors. applies to inbound listeners. Orleans applies every registered middleware instance in dependency-injection registration order. -## The middleware interface - -The interface defines `OnConnectionAsync(ConnectionContext, ConnectionDelegate)`. A middleware instance can be invoked concurrently for multiple connections, so store per-connection state in local variables or on the , not on the middleware instance. Implementations should call `next(context)` to continue the pipeline after performing their work; not calling `next` terminates the connection. +Orleans uses transport middleware to apply TLS. See [Secure Orleans connections with TLS](transport-layer-security.md) for the built-in security configuration. ## Register middleware -Use on an to add middleware to a connection pipeline: +Register connector and listener middleware as singleton services: :::code language="csharp" source="./snippets/connection-middleware/ConnectionMiddlewareExamples.cs" id="RegisterMiddleware"::: -Connection pipelines are configured through (silo) and (client). A silo has three distinct pipelines: +The middleware instances can be invoked for multiple connectors or listeners, so keep per-connection state in the returned decorator or the resulting . + +## Decorate outbound connectors -:::code language="csharp" source="./snippets/connection-middleware/ConnectionMiddlewareExamples.cs" id="SiloPipelines"::: +A connector middleware receives the next and returns the connector Orleans should use. The decorator can observe connection attempts, select another transport, or wrap the returned message transport: -An Orleans client has a single outbound pipeline, configured with : +:::code language="csharp" source="./snippets/connection-middleware/ConnectionMiddlewareExamples.cs" id="ConnectorDecorator"::: -:::code language="csharp" source="./snippets/connection-middleware/ConnectionMiddlewareExamples.cs" id="ClientPipeline"::: +Delegate , `IsValid`, and disposal unless the middleware deliberately changes those guarantees. -Middleware added first runs first, wrapping every middleware added after it, matching the order `UseMiddleware` is called. Register the middleware type itself (for example `MyClientSideMiddleware`, `MyServerSideMiddleware`) as a singleton service when using the generic `UseMiddleware()` overload. +Orleans owns the connector decorators returned by middleware. During host disposal, the connection factory closes connection-establishment admission, awaits admitted attempts, and disposes the decorated connector chain. Each decorator disposes its own resources and delegates disposal to its inner connector. The service provider owns the registered transport connector and disposes it separately. -> [!NOTE] -> Silo-to-silo and client-to-silo connections perform an Orleans-internal handshake after the connection pipeline runs. Middleware that terminates or replaces the transport (such as TLS) must leave a working `ConnectionContext` in place before calling `next`, because Orleans handshake and message framing execute afterward. +## Decorate inbound listeners -## Read and write framed data +A listener middleware receives the next and returns the listener Orleans should bind and accept from: -Custom middleware that exchanges its own protocol data before calling `next` (for example, a handshake) can read and write directly from `context.Transport.Input`/`Output` (`PipeReader`/`PipeWriter`), or use for structured, length-prefixed frames. `ConnectionFrameHelper` is optional; it exists to save middleware authors from re-implementing length-prefixed framing. +:::code language="csharp" source="./snippets/connection-middleware/ConnectionMiddlewareExamples.cs" id="ListenerDecorator"::: -The wire format per frame is `[4-byte little-endian length][1-byte frame type][payload]`, where the length equals `1 + payload.Length`. +Preserve the listener name so Orleans can select the silo and gateway listeners by role. Return `null` from `AcceptAsync` when the inner listener has stopped, matching the listener contract. -:::code language="csharp" source="./snippets/connection-middleware/ConnectionMiddlewareExamples.cs" id="ServerMiddleware"::: +## Migrate byte-stream middleware -`ConnectionFrameHelper` also provides `WriteLengthPrefixedString`/`ReadLengthPrefixedString` helpers for encoding UTF-8 strings inside a frame payload, and a zero-copy `WriteFrameAsync` overload that writes the payload directly into the transport pipe buffer via an `Action>` delegate, avoiding an intermediate `byte[]` allocation. +Customizations previously registered through `ClientConnectionOptions`, `SiloConnectionOptions`, or `IConnectionMiddleware` migrate to the connector and listener decorators shown above. Register outbound behavior as connector middleware and inbound behavior as listener middleware. Listener decorators can use the listener name to configure the `silo` and `gateway` roles independently. -`ReadFrameAsync` throws `InvalidOperationException` if the connection is closed mid-frame or if the declared frame length exceeds `maxFrameLength` (`ConnectionFrameHelper.DefaultMaxFrameLength`, 1 MB, by default). Pass a smaller `maxFrameLength` if your protocol's frames are bounded more tightly, to fail fast on malformed or hostile input. +The message transport request contracts own framing, completion, and buffer lifetimes. Decorators preserve those contracts while applying per-connection behavior. ## See also -- -- +- +- +- - [Secure Orleans connections with TLS](transport-layer-security.md) - [Server configuration](configuration-guide/server-configuration.md) - [Client configuration](configuration-guide/client-configuration.md) diff --git a/docs/site/src/content/docs/host/snippets/connection-middleware/ConnectionMiddlewareExamples.cs b/docs/site/src/content/docs/host/snippets/connection-middleware/ConnectionMiddlewareExamples.cs index 0b7f0d5f57b..a0f505a26ec 100644 --- a/docs/site/src/content/docs/host/snippets/connection-middleware/ConnectionMiddlewareExamples.cs +++ b/docs/site/src/content/docs/host/snippets/connection-middleware/ConnectionMiddlewareExamples.cs @@ -1,128 +1,84 @@ -using System.Text; -using Microsoft.AspNetCore.Connections; +using System.Net; using Microsoft.Extensions.DependencyInjection; -using Orleans; -using Orleans.Configuration; -using Orleans.Hosting; -using Orleans.Runtime.Messaging; +using Microsoft.Extensions.Logging; +using Orleans.Connections.Transport; namespace ConnectionMiddlewareSnippets; internal static class ConnectionMiddlewareExamples { // - public static void RegisterFromDependencyInjection( - IServiceCollection services, - IConnectionBuilder connectionBuilder) + public static void RegisterTransportMiddleware(IServiceCollection services) { - services.AddSingleton(); - connectionBuilder.UseMiddleware(); - } - - public static void RegisterInstance(IConnectionBuilder connectionBuilder) - { - // The caller owns this shared instance and is responsible for disposing it. - connectionBuilder.UseMiddleware(new MyClientSideMiddleware()); + services.AddSingleton(); + services.AddSingleton(); } // +} - // - public static void ConfigureSilo(ISiloBuilder siloBuilder) - { - siloBuilder.Services.AddSingleton(); - siloBuilder.Services.AddSingleton(); - - siloBuilder.Configure(options => - { - // Connections this silo makes to other silos. - options.ConfigureSiloOutboundConnection(connectionBuilder => - { - connectionBuilder.UseMiddleware(); - }); +internal sealed class LoggingConnectorMiddleware(ILoggerFactory loggerFactory) + : IMessageTransportConnectorMiddleware +{ + public MessageTransportConnector Apply(MessageTransportConnector transport) => + new LoggingConnector(transport, loggerFactory.CreateLogger()); +} - // Connections this silo accepts from other silos. - options.ConfigureSiloInboundConnection(connectionBuilder => - { - connectionBuilder.UseMiddleware(); - }); +internal sealed class LoggingListenerMiddleware(ILoggerFactory loggerFactory) + : IMessageTransportListenerMiddleware +{ + public MessageTransportListener Apply(MessageTransportListener listener) => + new LoggingListener(listener, loggerFactory.CreateLogger()); +} - // Connections this silo accepts from Orleans clients through the gateway. - options.ConfigureGatewayInboundConnection(connectionBuilder => - { - connectionBuilder.UseMiddleware(); - }); - }); - } - // +// +internal sealed class LoggingConnector( + MessageTransportConnector inner, + ILogger logger) : MessageTransportConnector +{ + public override IFeatureCollection Features => inner.Features; + public override bool IsValid => inner.IsValid; - // - public static void ConfigureClient(IClientBuilder clientBuilder) + public override async ValueTask CreateAsync( + EndPoint endpoint, + CancellationToken cancellationToken = default) { - clientBuilder.Services.AddSingleton(); - - clientBuilder.Configure(options => - { - options.ConfigureConnection(connectionBuilder => - { - connectionBuilder.UseMiddleware(); - }); - }); + logger.LogInformation("Connecting Orleans transport to {Endpoint}", endpoint); + return await inner.CreateAsync(endpoint, cancellationToken); } - // + + public override ValueTask DisposeAsync() => inner.DisposeAsync(); } +// -internal sealed class MyClientSideMiddleware : IConnectionMiddleware +// +internal sealed class LoggingListener( + MessageTransportListener inner, + ILogger logger) : MessageTransportListener { - public async Task OnConnectionAsync( - ConnectionContext context, - ConnectionDelegate next) - { - var requestPayload = Encoding.UTF8.GetBytes("hello"); - await ConnectionFrameHelper.WriteFrameAsync( - context, frameType: 0x01, requestPayload, context.ConnectionClosed); + public override bool IsValid => inner.IsValid; + public override string ListenerName => inner.ListenerName; + public override IFeatureCollection Features => inner.Features; - var (frameType, payload) = await ConnectionFrameHelper.ReadFrameAsync( - context, context.ConnectionClosed); + public override ValueTask BindAsync(CancellationToken cancellationToken = default) => + inner.BindAsync(cancellationToken); - if (frameType != 0x02 - || !string.Equals( - Encoding.UTF8.GetString(payload), - "ok", - StringComparison.Ordinal)) + public override async ValueTask AcceptAsync( + CancellationToken cancellationToken = default) + { + var transport = await inner.AcceptAsync(cancellationToken); + if (transport is not null) { - throw new InvalidOperationException("Unexpected handshake response."); + logger.LogInformation( + "Accepted Orleans transport on listener {ListenerName}", + ListenerName); } - await next(context); + return transport; } -} -// -internal sealed class MyServerSideMiddleware : IConnectionMiddleware -{ - public async Task OnConnectionAsync( - ConnectionContext context, - ConnectionDelegate next) - { - // Read one frame of the custom handshake protocol. - var (frameType, payload) = await ConnectionFrameHelper.ReadFrameAsync( - context, context.ConnectionClosed); - - if (frameType != 0x01 - || !string.Equals( - Encoding.UTF8.GetString(payload), - "hello", - StringComparison.Ordinal)) - { - throw new InvalidOperationException("Unexpected handshake request."); - } - - var responsePayload = Encoding.UTF8.GetBytes("ok"); - await ConnectionFrameHelper.WriteFrameAsync( - context, frameType: 0x02, responsePayload, context.ConnectionClosed); + public override ValueTask UnbindAsync(CancellationToken cancellationToken = default) => + inner.UnbindAsync(cancellationToken); - // Continue the pipeline; Orleans's own handshake and framing run after this. - await next(context); - } + public override ValueTask DisposeAsync() => inner.DisposeAsync(); } -// +// diff --git a/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/ClientExample/ClientExample.csproj b/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/ClientExample/ClientExample.csproj index f66a2d1fa99..9e46c5973a5 100644 --- a/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/ClientExample/ClientExample.csproj +++ b/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/ClientExample/ClientExample.csproj @@ -5,11 +5,11 @@ net10.0 enable enable + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\..\..\..\..\..\..\..\..\')) - - + diff --git a/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/ClientExample/Program.cs b/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/ClientExample/Program.cs index cb51b4d150a..d7fd2ab714e 100644 --- a/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/ClientExample/Program.cs +++ b/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/ClientExample/Program.cs @@ -1,6 +1,6 @@ using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Hosting; -using Orleans.Connections.Security; +using Orleans.Connections.Transport.Security; using Orleans.Hosting; Console.WriteLine("TLS configuration examples"); diff --git a/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/SiloExample/Program.cs b/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/SiloExample/Program.cs index 96c75624662..3339b506e73 100644 --- a/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/SiloExample/Program.cs +++ b/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/SiloExample/Program.cs @@ -1,6 +1,6 @@ using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Hosting; -using Orleans.Connections.Security; +using Orleans.Connections.Transport.Security; using Orleans.Hosting; Console.WriteLine("TLS configuration examples"); diff --git a/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/SiloExample/SiloExample.csproj b/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/SiloExample/SiloExample.csproj index 002ac7c5eb1..db0e7ef53db 100644 --- a/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/SiloExample/SiloExample.csproj +++ b/docs/site/src/content/docs/host/snippets/transport-layer-security/csharp/SiloExample/SiloExample.csproj @@ -5,11 +5,11 @@ net10.0 enable enable + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\..\..\..\..\..\..\..\..\')) - - + diff --git a/docs/site/src/content/docs/host/transport-layer-security.md b/docs/site/src/content/docs/host/transport-layer-security.md index fe8fb9cb0a7..daec67a665e 100644 --- a/docs/site/src/content/docs/host/transport-layer-security.md +++ b/docs/site/src/content/docs/host/transport-layer-security.md @@ -10,9 +10,22 @@ ms.topic: how-to Orleans can protect client-to-silo and silo-to-silo connections with Transport Layer Security (TLS). TLS encrypts traffic and authenticates the endpoint acting as the TLS server. Mutual TLS (mTLS) additionally requires and authenticates the endpoint acting as the TLS client. > [!IMPORTANT] -> defaults to `RequireCertificate`. On a silo's inbound connections, this default requires the connecting silo or Orleans client to present a certificate. To configure server-authenticated TLS without client certificates, explicitly set it to `NoCertificate` on every silo. +> defaults to `RequireCertificate`. On a silo's inbound connections, this default requires the connecting silo or Orleans client to present a certificate. To configure server-authenticated TLS without client certificates, explicitly set it to `NoCertificate` on every silo. -Install [Microsoft.Orleans.Connections.Security](https://www.nuget.org/packages/Microsoft.Orleans.Connections.Security) in every silo and client process. +The TLS APIs are included in the Orleans server and client packages. + +## Migrate TLS configuration + +TLS configuration and certificate loading now reside in the Orleans Core and Runtime packages. Applications migrating from `Microsoft.Orleans.Connections.Security` update their references as follows: + +| Previous API | Current API | +|---|---| +| `Orleans.Hosting.OrleansConnectionSecurityHostingExtensions` client overloads | | +| `Orleans.Hosting.OrleansConnectionSecurityHostingExtensions` silo overloads | | +| `Orleans.Connections.Security.CertificateLoader` | | +| `Orleans.Connections.Security` TLS options and features | `Orleans.Connections.Transport.Security` | + +The `builder.UseTls(...)` call syntax is preserved. Explicit static calls use the client or silo hosting type listed above. This package and assembly consolidation is a binary API change: rebuild applications and libraries against the message transport APIs and deploy those rebuilt binaries together. ## Choose an authentication model @@ -23,8 +36,8 @@ Install [Microsoft.Orleans.Connections.Security](https://www.nuget.org/packages/ The two similarly named options apply at different stages: -- controls whether the remote endpoint must present a certificate. In server middleware, `RequireCertificate` requires a certificate from an inbound Orleans client or silo, `AllowCertificate` requests one but permits none, and `NoCertificate` doesn't request one. Silo configuration uses this same value for outbound middleware; the TLS server still presents a certificate and platform validation authenticates it when no custom callback is installed. -- controls selection of the local client certificate in client middleware. On a silo, it applies when the silo initiates a silo-to-silo connection. On an Orleans client, it applies when the client initiates a gateway connection. It doesn't control inbound silo behavior. +- controls peer certificate requirements. On inbound silo connections, `RequireCertificate` requires a certificate from the connecting Orleans client or silo, `AllowCertificate` requests one and accepts its absence, and `NoCertificate` omits client-certificate requests. On outbound connections, this option governs validation of the TLS server's certificate. +- controls selection of the local client certificate whenever Orleans initiates a TLS connection: silo-to-silo connections for a silo and gateway connections for an Orleans client. `ClientCertificateMode` defaults to `AllowCertificate`: a configured local certificate is sent when it's valid for client authentication, but a missing or unsuitable local certificate is tolerated. Setting it to `RequireCertificate` makes the outbound requirement explicit and fails configuration or connection setup when an appropriate local certificate isn't available. @@ -64,7 +77,7 @@ Configure an Orleans client without a local certificate: :::code language="csharp" source="./snippets/transport-layer-security/csharp/ClientExample/Program.cs" id="ServerAuthenticatedTls"::: - must match a DNS Subject Alternative Name (SAN) on the server certificate. Use the stable service name clients use to reach the silos, not an arbitrary certificate subject. + must match a DNS Subject Alternative Name (SAN) on the server certificate. Use the stable service name clients use to reach the silos, not an arbitrary certificate subject. ## Configure mutual TLS @@ -74,10 +87,10 @@ For mTLS, silos require a certificate from every inbound Orleans client or silo :::code language="csharp" source="./snippets/transport-layer-security/csharp/ClientExample/Program.cs" id="MutualTls"::: -The default platform validation applies when isn't set. If you provide that callback, keep normal chain and name checks and add only the deployment-specific policy you require. A callback which returns `true` unconditionally defeats peer authentication. +The default platform validation applies when isn't set. If you provide that callback, keep normal chain and name checks and add only the deployment-specific policy you require. A callback which returns `true` unconditionally defeats peer authentication. > [!WARNING] -> is suitable only for isolated local development. It accepts any remote certificate and therefore doesn't protect against impersonation or an active man-in-the-middle. +> is suitable only for isolated local development. It accepts any remote certificate and therefore doesn't protect against impersonation or an active man-in-the-middle. ## Establish certificate trust @@ -91,9 +104,11 @@ Keep trust stores narrow. Don't place unrelated public or corporate roots in a w ## Protocols and revocation - defaults to TLS 1.2 and TLS 1.3. Retain those defaults unless an interoperability or policy requirement calls for a narrower set. Orleans doesn't enable TLS 1.0 or TLS 1.1 by default. +The TLS transports negotiate the `Orleans1` application-layer protocol identifier, preserving protocol negotiation with existing Orleans endpoints. Authentication callbacks can access the through the message transport's feature collection. + + defaults to TLS 1.2 and TLS 1.3. Retain those defaults unless an interoperability or policy requirement calls for a narrower set. Orleans doesn't enable TLS 1.0 or TLS 1.1 by default. -Set to check remote certificates on inbound silo connections. For outbound connections, set in . Before enabling revocation checks, verify that every workload can reach the certificate revocation list (CRL) or Online Certificate Status Protocol (OCSP) service and decide how outages should affect availability. +Set to check remote certificates on inbound silo connections. For outbound connections, set in . Before enabling revocation checks, verify that every workload can reach the certificate revocation list (CRL) or Online Certificate Status Protocol (OCSP) service and decide how outages should affect availability. ## Rotate certificates @@ -102,7 +117,7 @@ Plan rotation before deployment: 1. Issue the replacement certificate with the same required names and EKUs. 2. Distribute the new issuing chain to trust stores before any endpoint presents the new certificate. 3. Make both old and new chains valid during an overlap window. -4. Restart processes with the replacement certificate, or use and to select certificates dynamically. +4. Restart processes with the replacement certificate, or use and to select certificates dynamically. 5. Confirm new connections use the replacement, then remove the old certificate and obsolete trust roots. Certificate selectors are called during authentication, but certificate loading, caching, disposal, and refresh are application responsibilities. Test rotation under normal reconnect and silo restart behavior. Alert on certificate expiration well before the overlap window closes. @@ -123,8 +138,9 @@ Certificate selectors are called during authentication, but certificate loading, - [Orleans security](../security/index.md) - [Network hardening](../security/networking.md) -- -- +- +- +- - [Client configuration](configuration-guide/client-configuration.md) - [Server configuration](configuration-guide/server-configuration.md) - [.NET TLS/SSL best practices](https://learn.microsoft.com/dotnet/core/extensions/sslstream-best-practices) diff --git a/docs/site/src/content/docs/implementation/messaging-networking.md b/docs/site/src/content/docs/implementation/messaging-networking.md index 079429df77c..d0f6690adca 100644 --- a/docs/site/src/content/docs/implementation/messaging-networking.md +++ b/docs/site/src/content/docs/implementation/messaging-networking.md @@ -7,7 +7,7 @@ ms.topic: concept-article # Transport and networking internals -Orleans separates message routing from transport management. `MessageCenter` decides where a message should go; `ConnectionManager` obtains a usable connection to the target silo; the connection implementation frames, queues, and writes messages to a socket. This separation lets routing repair activation addresses while transport focuses on connection management. +Orleans separates message routing from transport management. `MessageCenter` decides where a message should go; `ConnectionManager` obtains a usable connection to the target silo; the connection implementation batches and frames messages into requests for a message transport. This separation lets routing repair activation addresses while the transport manages reads, writes, and completion callbacks. ## Connection lifecycle @@ -17,23 +17,25 @@ Connection establishment has a bounded `OpenConnectionTimeout`. A failed attempt Source: [`ConnectionManager`](https://github.com/dotnet/orleans/blob/main/src/Orleans.Core/Networking/ConnectionManager.cs), [`Connection`](https://github.com/dotnet/orleans/blob/main/src/Orleans.Core/Networking/Connection.cs), and [`SiloConnection`](https://github.com/dotnet/orleans/blob/main/src/Orleans.Runtime/Networking/SiloConnection.cs). +The TCP transport enables keep-alive on outbound and accepted sockets. By default, the operating system sends the first probe after 90 seconds of idle time, then probes every 30 seconds and allows 10 unanswered probes before terminating the connection. Accepted sockets use the current options for their named listener, so each silo or gateway listener applies its configured keep-alive policy to new connections. When the platform reports an unsupported keep-alive option, the transport logs that option and retains supported settings; tuning continues for the other supported options. + ## Message path and framing When `MessageCenter.SendMessage` has a target silo, it reuses an existing connection or obtains one from `ConnectionManager`. A local target uses receive processing directly. A known-dead target causes a transient rejection for request and one-way messages, and expired messages are dropped before consuming transport work. -The connection pipeline performs the protocol preamble and then exchanges framed payloads. `MessageSerializer` encodes the message header and body using Orleans serialization; the frame helper validates lengths and rejects malformed input before dispatch. TLS wraps the same Orleans framing and message protocol as connection middleware. +The connection pipeline performs the protocol preamble and then exchanges framed payloads. `MessageSerializer` encodes the message header and body using Orleans serialization. `MessageReadRequest` validates frame lengths before consuming the header and body. The body remains buffered until deserialization, forwarding, or disposal releases it. TLS decorates the message transport while preserving Orleans framing and the message protocol. -The connection accepts outgoing messages through an unbounded channel. Its socket writer observes transport flow control while concurrent senders can continue adding messages to that channel, so a slow connection can accumulate queued messages until it recovers or closes. A successful socket write means the frame was handed to the transport; the grain result confirms request completion. Disconnects remove the connection from the endpoint entry and cause later sends to establish a replacement. +The connection accepts outgoing messages through an unbounded queue. Its send worker batches messages into write requests, and the transport writer observes flow control while concurrent senders continue adding messages to the connection queue. A slow connection can therefore accumulate queued messages until it recovers or closes. A successful write completes the request and releases its buffered message bodies; the grain result confirms application request completion. Disconnects remove the connection from the endpoint entry and cause later sends to establish a replacement. ## Failure and shutdown -When a connection terminates, the base connection sends each in-flight message to the connection-specific `OnSendMessageFailure` implementation and calls `RetryMessage` for messages which remain queued. Silo and gateway inbound connections fail in-flight messages; client outbound connections return them to `MessageCenter` for routing. Application-level response timeouts remain ambiguous because a request can have reached the target before the connection failed. +When a connection terminates, failed write requests and queued send work reroute their messages through the connection-specific `RetryMessage` implementation. That implementation returns messages to `MessageCenter` for routing up to the retry limit, then rejects requests and drops other messages. A partially written request can have reached its target before failure, so applications reconcile ambiguous outcomes using their delivery and idempotency requirements. -Shutdown first blocks new application traffic while allowing responses and membership traffic needed to complete the stop protocol. `MessageCenter` rejects or drops blocked messages, stops accepting client messages, and then closes connections. Inbound requests arriving at a stopping silo are rejected or dropped according to message direction, so callers must still handle a rejection or timeout. +Shutdown first blocks new application traffic while allowing responses and membership traffic needed to complete the stop protocol. `MessageCenter` rejects or drops blocked messages, stops accepting client messages, and then closes connections. Inbound application requests arriving at a stopping silo receive transient rejections with targeted cache invalidation, allowing senders to remove routes to that silo. Other inbound application messages are dropped, and their buffered bodies are released. `ConnectionManager` closes establishment admission before canceling pending attempts and signaling existing connections to close. Each admitted producer remains owned until it publishes its connection or completes failure cleanup, including its connection runner. Shutdown then closes any late publications and drains the remaining outbound runners through middleware cleanup. The shutdown cancellation source stays valid until those operations finish; a canceled host stop preserves it for operations still unwinding. Inbound listeners stop accepting connections and close their tracked connections before awaiting the manager's closed signal and disposing their transport listener. -Connection closure aborts the underlying transport to release pending TLS handshakes and Orleans preamble exchange. Middleware then completes its cleanup and restores the original transport before the connection context is disposed. A connection closed before its queued runner starts completes this same transport shutdown while preventing middleware startup. +Connection closure stops send-work admission and closes the message transport to release pending TLS handshakes, preamble exchange, and transport I/O. After the connection runner and send worker quiesce, transport disposal releases the decorated transport chain. A connection closed before its queued runner starts completes transport shutdown before protocol initialization. ## Design trade-offs diff --git a/docs/site/src/content/docs/resources/nuget-packages.md b/docs/site/src/content/docs/resources/nuget-packages.md index 3691222a92c..6858d4ef0c9 100644 --- a/docs/site/src/content/docs/resources/nuget-packages.md +++ b/docs/site/src/content/docs/resources/nuget-packages.md @@ -28,7 +28,6 @@ For installation guidance, see [`dotnet package add`](https://learn.microsoft.co | [Microsoft.Orleans.Hosting.Kubernetes](https://www.nuget.org/packages/Microsoft.Orleans.Hosting.Kubernetes) | Kubernetes hosting integration. | | [Microsoft.Orleans.Dashboard](https://www.nuget.org/packages/Microsoft.Orleans.Dashboard) | Built-in Orleans Dashboard server and UI. | | [Microsoft.Orleans.Dashboard.Abstractions](https://www.nuget.org/packages/Microsoft.Orleans.Dashboard.Abstractions) | Dashboard contracts for components which don't host the UI. | -| [Microsoft.Orleans.Connections.Security](https://www.nuget.org/packages/Microsoft.Orleans.Connections.Security) | TLS support for Orleans connections. | `Microsoft.Orleans.Runtime`, `Microsoft.Orleans.Core`, and the abstractions packages are lower-level dependencies of the metapackages. Reference them directly only when building a library with a narrower dependency requirement. diff --git a/samples/TransportLayerSecurity/Directory.Packages.props b/samples/TransportLayerSecurity/Directory.Packages.props index e5c05d42ae3..b03e68a9c35 100644 --- a/samples/TransportLayerSecurity/Directory.Packages.props +++ b/samples/TransportLayerSecurity/Directory.Packages.props @@ -8,7 +8,6 @@ - diff --git a/samples/TransportLayerSecurity/TLS.Client/TLS.Client.csproj b/samples/TransportLayerSecurity/TLS.Client/TLS.Client.csproj index c2401ca2d0e..3cb21dfbe6a 100644 --- a/samples/TransportLayerSecurity/TLS.Client/TLS.Client.csproj +++ b/samples/TransportLayerSecurity/TLS.Client/TLS.Client.csproj @@ -10,7 +10,6 @@ - diff --git a/samples/TransportLayerSecurity/TLS.Server/TLS.Server.csproj b/samples/TransportLayerSecurity/TLS.Server/TLS.Server.csproj index e0bf91b02d7..7fde2dd0f93 100644 --- a/samples/TransportLayerSecurity/TLS.Server/TLS.Server.csproj +++ b/samples/TransportLayerSecurity/TLS.Server/TLS.Server.csproj @@ -9,7 +9,6 @@ - diff --git a/src/Orleans.Connections.Security/Hosting/HostingExtensions.cs b/src/Orleans.Connections.Security/Hosting/HostingExtensions.cs deleted file mode 100644 index 8e163842297..00000000000 --- a/src/Orleans.Connections.Security/Hosting/HostingExtensions.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Security.Cryptography.X509Certificates; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Orleans.Connections.Security; - -namespace Orleans -{ - /// - /// Provides extension methods for adding TLS middleware to Orleans connections. - /// - public static class TlsConnectionBuilderExtensions - { - /// - /// Adds TLS server authentication and encryption to the connection pipeline. - /// - /// The connection pipeline builder. - /// The TLS configuration. - public static void UseServerTls( - this IConnectionBuilder builder, - TlsOptions options) - { - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } - - var loggerFactory = builder.ApplicationServices.GetService(typeof(ILoggerFactory)) as ILoggerFactory ?? NullLoggerFactory.Instance; - builder.UseMiddleware(new TlsServerConnectionMiddleware(options, loggerFactory)); - } - - /// - /// Adds TLS client authentication and encryption to the connection pipeline. - /// - /// The connection pipeline builder. - /// The TLS configuration. - public static void UseClientTls( - this IConnectionBuilder builder, - TlsOptions options) - { - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } - - var loggerFactory = builder.ApplicationServices.GetService(typeof(ILoggerFactory)) as ILoggerFactory ?? NullLoggerFactory.Instance; - builder.UseMiddleware(new TlsClientConnectionMiddleware(options, loggerFactory)); - } - - internal static void ThrowNoPrivateKey(X509Certificate2 certificate, string parameterName) - { - throw new ArgumentException($"Certificate {certificate.ToString(verbose: true)} does not contain a private key", parameterName); - } - } -} diff --git a/src/Orleans.Connections.Security/Orleans.Connections.Security.csproj b/src/Orleans.Connections.Security/Orleans.Connections.Security.csproj deleted file mode 100644 index 8a54b288b39..00000000000 --- a/src/Orleans.Connections.Security/Orleans.Connections.Security.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - Microsoft.Orleans.Connections.Security - Microsoft Orleans TLS support - Support for security communication using TLS in Microsoft Orleans. - $(PackageTags) TLS SSL - $(DefaultTargetFrameworks) - true - - - - - - - - - - diff --git a/src/Orleans.Connections.Security/Security/CertificateLoader.cs b/src/Orleans.Connections.Security/Security/CertificateLoader.cs deleted file mode 100644 index 35b3572f44e..00000000000 --- a/src/Orleans.Connections.Security/Security/CertificateLoader.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Linq; -using System.Security.Cryptography.X509Certificates; - -namespace Orleans.Connections.Security -{ - /// - /// Provides methods for loading TLS certificates from an operating system certificate store. - /// - public static class CertificateLoader - { - // See http://oid-info.com/get/1.3.6.1.5.5.7.3.1 - // Indicates that a certificate can be used as a TLS server certificate - private const string ServerAuthenticationOid = "1.3.6.1.5.5.7.3.1"; - - // See http://oid-info.com/get/1.3.6.1.5.5.7.3.2 - // Indicates that a certificate can be used as a TLS client certificate - private const string ClientAuthenticationOid = "1.3.6.1.5.5.7.3.2"; - - /// - /// Loads the newest matching certificate which has an accessible private key and is valid for the requested TLS role. - /// - /// The subject name to search for. - /// The name of the certificate store. - /// The location of the certificate store. - /// - /// to include certificates which do not pass certificate-chain validation; - /// otherwise, . - /// - /// - /// to select a certificate valid for server authentication; - /// to select one valid for client authentication. - /// - /// The selected certificate. - /// No suitable certificate was found. - public static X509Certificate2 LoadFromStoreCert(string subject, string storeName, StoreLocation storeLocation, bool allowInvalid, bool server) - { - using (var store = new X509Store(storeName, storeLocation)) - { - X509Certificate2Collection? storeCertificates = null; - X509Certificate2? foundCertificate = null; - - try - { - store.Open(OpenFlags.ReadOnly); - storeCertificates = store.Certificates; - var foundCertificates = storeCertificates.Find(X509FindType.FindBySubjectName, subject, !allowInvalid); - foundCertificate = foundCertificates - .OfType() - .Where(c => server ? IsCertificateAllowedForServerAuth(c) : IsCertificateAllowedForClientAuth(c)) - .Where(DoesCertificateHaveAnAccessiblePrivateKey) - .OrderByDescending(certificate => certificate.NotAfter) - .FirstOrDefault(); - - if (foundCertificate == null) - { - throw new InvalidOperationException($"Certificate {subject} not found in store {storeLocation} / {storeName}. AllowInvalid: {allowInvalid}"); - } - - return foundCertificate; - } - finally - { - DisposeCertificates(storeCertificates, except: foundCertificate); - } - } - } - - internal static bool IsCertificateAllowedForServerAuth(X509Certificate2 certificate) => IsCertificateAllowedForKeyUsage(certificate, ServerAuthenticationOid); - - internal static bool IsCertificateAllowedForClientAuth(X509Certificate2 certificate) => IsCertificateAllowedForKeyUsage(certificate, ClientAuthenticationOid); - - private static bool IsCertificateAllowedForKeyUsage(X509Certificate2 certificate, string purposeOid) - { - /* If the Extended Key Usage extension is included, then we check that the serverAuth usage is included. (http://oid-info.com/get/1.3.6.1.5.5.7.3.1) - * If the Extended Key Usage extension is not included, then we assume the certificate is allowed for all usages. - * - * See also https://blogs.msdn.microsoft.com/kaushal/2012/02/17/client-certificates-vs-server-certificates/ - * - * From https://tools.ietf.org/html/rfc3280#section-4.2.1.13 "Certificate Extensions: Extended Key Usage" - * - * If the (Extended Key Usage) extension is present, then the certificate MUST only be used - * for one of the purposes indicated. If multiple purposes are - * indicated the application need not recognize all purposes indicated, - * as long as the intended purpose is present. Certificate using - * applications MAY require that a particular purpose be indicated in - * order for the certificate to be acceptable to that application. - */ - - var hasEkuExtension = false; - - foreach (var extension in certificate.Extensions.OfType()) - { - hasEkuExtension = true; - foreach (var oid in extension.EnhancedKeyUsages) - { - if (oid.Value!.Equals(purposeOid, StringComparison.Ordinal)) - { - return true; - } - } - } - - return !hasEkuExtension; - } - - internal static bool DoesCertificateHaveAnAccessiblePrivateKey(X509Certificate2 certificate) - => certificate.HasPrivateKey; - - private static void DisposeCertificates(X509Certificate2Collection? certificates, X509Certificate2? except) - { - if (certificates != null) - { - foreach (var certificate in certificates) - { - if (!certificate.Equals(except)) - { - certificate.Dispose(); - } - } - } - } - } -} diff --git a/src/Orleans.Connections.Security/Security/DuplexPipeStream.cs b/src/Orleans.Connections.Security/Security/DuplexPipeStream.cs deleted file mode 100644 index 3f371d216c6..00000000000 --- a/src/Orleans.Connections.Security/Security/DuplexPipeStream.cs +++ /dev/null @@ -1,280 +0,0 @@ -using System; -using System.Buffers; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.IO.Pipelines; -using System.Threading; -using System.Threading.Tasks; - -namespace Orleans.Connections.Security -{ - internal class DuplexPipeStream : Stream - { - private readonly PipeReader _reader; - private readonly PipeWriter _writer; - - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => true; - public override long Length => throw new NotSupportedException(); - public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } - - public DuplexPipeStream(IDuplexPipe pipe) - { - _reader = pipe.Input; - _writer = pipe.Output; - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - _reader.Complete(); - _writer.Complete(); - } - base.Dispose(disposing); - } - - public override async ValueTask DisposeAsync() - { - await _reader.CompleteAsync().ConfigureAwait(false); - await _writer.CompleteAsync().ConfigureAwait(false); - } - - public override void Flush() - { - FlushAsync().GetAwaiter().GetResult(); - } - - public override async Task FlushAsync(CancellationToken cancellationToken) - { - FlushResult r = await _writer.FlushAsync(cancellationToken).ConfigureAwait(false); - if (r.IsCanceled) throw new OperationCanceledException(cancellationToken); - } - - public override int Read(byte[] buffer, int offset, int count) - { - ValidateBufferArguments(buffer, offset, count); - - ValueTask t = ReadAsync(buffer.AsMemory(offset, count)); - return - t.IsCompleted ? t.GetAwaiter().GetResult() : - t.AsTask().GetAwaiter().GetResult(); - } - - public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ValidateBufferArguments(buffer, offset, count); - - return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); - } - - public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) - { - ReadResult result = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false); - - if (result.IsCanceled) - { - throw new OperationCanceledException(); - } - - ReadOnlySequence sequence = result.Buffer; - long bufferLength = sequence.Length; - SequencePosition consumed = sequence.Start; - - try - { - if (bufferLength != 0) - { - int actual = (int)Math.Min(bufferLength, buffer.Length); - - ReadOnlySequence slice = actual == bufferLength ? sequence : sequence.Slice(0, actual); - consumed = slice.End; - slice.CopyTo(buffer.Span); - - return actual; - } - - if (result.IsCompleted) - { - return 0; - } - } - finally - { - _reader.AdvanceTo(consumed); - } - - // This is a buggy PipeReader implementation that returns 0 byte reads even though the PipeReader - // isn't completed or canceled. - throw new InvalidOperationException("Read zero bytes unexpectedly"); - } - - public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) - { - return TaskToApm.Begin(ReadAsync(buffer, offset, count), callback, state); - } - - public override int EndRead(IAsyncResult asyncResult) - { - return TaskToApm.End(asyncResult); - } - - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotSupportedException(); - } - - public override void SetLength(long value) - { - throw new NotSupportedException(); - } - - public override void Write(byte[] buffer, int offset, int count) - { - WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); - } - - public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ValidateBufferArguments(buffer, offset, count); - - return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - FlushResult r = await _writer.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); - if (r.IsCanceled) throw new OperationCanceledException(cancellationToken); - } - - public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) - { - return TaskToApm.Begin(WriteAsync(buffer, offset, count), callback, state); - } - - public override void EndWrite(IAsyncResult asyncResult) - { - TaskToApm.End(asyncResult); - } - - public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) - { - return _reader.CopyToAsync(destination, cancellationToken); - } - - /// - /// Provides support for efficiently using Tasks to implement the APM (Begin/End) pattern. - /// - internal static class TaskToApm - { - /// - /// Marshals the Task as an IAsyncResult, using the supplied callback and state - /// to implement the APM pattern. - /// - /// The Task to be marshaled. - /// The callback to be invoked upon completion. - /// The state to be stored in the IAsyncResult. - /// An IAsyncResult to represent the task's asynchronous operation. - public static IAsyncResult Begin(Task task, AsyncCallback? callback, object? state) => - new TaskAsyncResult(task, state, callback); - - /// Processes an IAsyncResult returned by Begin. - /// The IAsyncResult to unwrap. - public static void End(IAsyncResult asyncResult) - { - if (GetTask(asyncResult) is Task t) - { - t.GetAwaiter().GetResult(); - return; - } - - ThrowArgumentException(asyncResult); - } - - /// Processes an IAsyncResult returned by Begin. - /// The IAsyncResult to unwrap. - public static TResult End(IAsyncResult asyncResult) - { - if (GetTask(asyncResult) is Task task) - { - return task.GetAwaiter().GetResult(); - } - - ThrowArgumentException(asyncResult); - return default!; // Unreachable because ThrowArgumentException always throws. - } - - /// Gets the task represented by the IAsyncResult. - public static Task? GetTask(IAsyncResult asyncResult) => (asyncResult as TaskAsyncResult)?._task; - - /// Throws an argument exception for the invalid . - [DoesNotReturn] - private static void ThrowArgumentException(IAsyncResult asyncResult) => - throw (asyncResult is null ? - new ArgumentNullException(nameof(asyncResult)) : - new ArgumentException(null, nameof(asyncResult))); - - /// Provides a simple IAsyncResult that wraps a Task. - /// - /// We could use the Task as the IAsyncResult if the Task's AsyncState is the same as the object state, - /// but that's very rare, in particular in a situation where someone cares about allocation, and always - /// using TaskAsyncResult simplifies things and enables additional optimizations. - /// - internal sealed class TaskAsyncResult : IAsyncResult - { - /// The wrapped Task. - internal readonly Task _task; - /// Callback to invoke when the wrapped task completes. - private readonly AsyncCallback? _callback; - - /// Initializes the IAsyncResult with the Task to wrap and the associated object state. - /// The Task to wrap. - /// The new AsyncState value. - /// Callback to invoke when the wrapped task completes. - internal TaskAsyncResult(Task task, object? state, AsyncCallback? callback) - { - Debug.Assert(task != null); - _task = task; - AsyncState = state; - - if (task.IsCompleted) - { - // Synchronous completion. Invoke the callback. No need to store it. - CompletedSynchronously = true; - callback?.Invoke(this); - } - else if (callback != null) - { - // Asynchronous completion, and we have a callback; schedule it. We use OnCompleted rather than ContinueWith in - // order to avoid running synchronously if the task has already completed by the time we get here but still run - // synchronously as part of the task's completion if the task completes after (the more common case). - _callback = callback; - _task.ConfigureAwait(continueOnCapturedContext: false) - .GetAwaiter() - .OnCompleted(InvokeCallback); // allocates a delegate, but avoids a closure - } - } - - /// Invokes the callback. - private void InvokeCallback() - { - Debug.Assert(!CompletedSynchronously); - Debug.Assert(_callback != null); - _callback.Invoke(this); - } - - /// Gets a user-defined object that qualifies or contains information about an asynchronous operation. - public object? AsyncState { get; } - /// Gets a value that indicates whether the asynchronous operation completed synchronously. - /// This is set lazily based on whether the has completed by the time this object is created. - public bool CompletedSynchronously { get; } - /// Gets a value that indicates whether the asynchronous operation has completed. - public bool IsCompleted => _task.IsCompleted; - /// Gets a that is used to wait for an asynchronous operation to complete. - public WaitHandle AsyncWaitHandle => ((IAsyncResult)_task).AsyncWaitHandle; - } - } - } -} diff --git a/src/Orleans.Connections.Security/Security/DuplexPipeStreamAdapter.cs b/src/Orleans.Connections.Security/Security/DuplexPipeStreamAdapter.cs deleted file mode 100644 index cdb6985eba5..00000000000 --- a/src/Orleans.Connections.Security/Security/DuplexPipeStreamAdapter.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.IO; -using System.IO.Pipelines; -using System.Threading; -using System.Threading.Tasks; - -namespace Orleans.Connections.Security -{ - /// - /// A helper for wrapping a Stream decorator from an . - /// - /// - internal class DuplexPipeStreamAdapter : DuplexPipeStream, IDuplexPipe where TStream : Stream - { - private bool _disposed; -#if NET9_0_OR_GREATER - private readonly Lock _disposeLock = new(); -#else - private readonly object _disposeLock = new(); -#endif - - public DuplexPipeStreamAdapter(IDuplexPipe duplexPipe, Func createStream) : - this(duplexPipe, new StreamPipeReaderOptions(leaveOpen: true), new StreamPipeWriterOptions(leaveOpen: true), createStream) - { - } - - public DuplexPipeStreamAdapter(IDuplexPipe duplexPipe, StreamPipeReaderOptions readerOptions, StreamPipeWriterOptions writerOptions, Func createStream) : - base(duplexPipe) - { - var stream = createStream(this); - Stream = stream; - Input = PipeReader.Create(stream, readerOptions); - Output = PipeWriter.Create(stream, writerOptions); - } - - public TStream Stream { get; } - - public PipeReader Input { get; } - - public PipeWriter Output { get; } - - public override async ValueTask DisposeAsync() - { - lock (_disposeLock) - { - if (_disposed) - { - return; - } - _disposed = true; - } - - await Input.CompleteAsync(); - await Output.CompleteAsync(); - } - - protected override void Dispose(bool disposing) - { - lock (_disposeLock) - { - if (_disposed) - { - return; - } - _disposed = true; - } - - if (disposing) - { - Input.Complete(); - Output.Complete(); - } - } - } -} diff --git a/src/Orleans.Connections.Security/Security/ITlsApplicationProtocolFeature.cs b/src/Orleans.Connections.Security/Security/ITlsApplicationProtocolFeature.cs deleted file mode 100644 index b4c8bdf025e..00000000000 --- a/src/Orleans.Connections.Security/Security/ITlsApplicationProtocolFeature.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace Orleans.Connections.Security -{ - /// - /// Provides access to the application protocol negotiated during the TLS handshake. - /// - public interface ITlsApplicationProtocolFeature - { - /// - /// Gets the negotiated application protocol bytes. - /// - ReadOnlyMemory ApplicationProtocol { get; } - } -} diff --git a/src/Orleans.Connections.Security/Security/ITlsConnectionFeature.cs b/src/Orleans.Connections.Security/Security/ITlsConnectionFeature.cs deleted file mode 100644 index ed44fa6fc1a..00000000000 --- a/src/Orleans.Connections.Security/Security/ITlsConnectionFeature.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Security.Cryptography.X509Certificates; -using System.Threading.Tasks; -using System.Threading; - -namespace Orleans.Connections.Security -{ - /// - /// Provides access to certificate information for an authenticated TLS connection. - /// - public interface ITlsConnectionFeature - { - /// - /// Synchronously retrieves the remote endpoint's certificate, if any. - /// - X509Certificate2? RemoteCertificate { get; set; } - - /// - /// Asynchronously retrieves the remote endpoint's certificate, if any. - /// - /// The cancellation token associated with the request. - /// A task which returns the remote endpoint's certificate, or if none is available. - Task GetRemoteCertificateAsync(CancellationToken cancellationToken); - } -} diff --git a/src/Orleans.Connections.Security/Security/ITlsHandshakeFeature.cs b/src/Orleans.Connections.Security/Security/ITlsHandshakeFeature.cs deleted file mode 100644 index f1386656fe7..00000000000 --- a/src/Orleans.Connections.Security/Security/ITlsHandshakeFeature.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Net.Security; -using System.Security.Authentication; - -namespace Orleans.Connections.Security -{ - /// - /// Provides information about the negotiated TLS handshake. - /// - public interface ITlsHandshakeFeature - { - /// - /// Gets the negotiated TLS protocol version. - /// - SslProtocols Protocol { get; } - - /// - /// Gets the . - /// - TlsCipherSuite? NegotiatedCipherSuite => null; - - /// - /// Gets the host name from the "server_name" (SNI) extension of the client hello if present. - /// - string HostName => string.Empty; - - /// - /// Gets the negotiated bulk encryption algorithm. - /// -#if NET10_0_OR_GREATER - [Obsolete("KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherStrength, HashAlgorithm and HashStrength properties are obsolete. Use NegotiatedCipherSuite instead.", DiagnosticId = "SYSLIB0058", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] -#endif - CipherAlgorithmType CipherAlgorithm { get; } - - /// - /// Gets the strength, in bits, of the negotiated bulk encryption algorithm. - /// -#if NET10_0_OR_GREATER - [Obsolete("KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherStrength, HashAlgorithm and HashStrength properties are obsolete. Use NegotiatedCipherSuite instead.", DiagnosticId = "SYSLIB0058", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] -#endif - int CipherStrength { get; } - - /// - /// Gets the negotiated message authentication algorithm. - /// -#if NET10_0_OR_GREATER - [Obsolete("KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherStrength, HashAlgorithm and HashStrength properties are obsolete. Use NegotiatedCipherSuite instead.", DiagnosticId = "SYSLIB0058", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] -#endif - HashAlgorithmType HashAlgorithm { get; } - - /// - /// Gets the strength, in bits, of the negotiated message authentication algorithm. - /// -#if NET10_0_OR_GREATER - [Obsolete("KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherStrength, HashAlgorithm and HashStrength properties are obsolete. Use NegotiatedCipherSuite instead.", DiagnosticId = "SYSLIB0058", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] -#endif - int HashStrength { get; } - - /// - /// Gets the negotiated key exchange algorithm. - /// -#if NET10_0_OR_GREATER - [Obsolete("KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherStrength, HashAlgorithm and HashStrength properties are obsolete. Use NegotiatedCipherSuite instead.", DiagnosticId = "SYSLIB0058", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] -#endif - ExchangeAlgorithmType KeyExchangeAlgorithm { get; } - - /// - /// Gets the strength, in bits, of the negotiated key exchange algorithm. - /// -#if NET10_0_OR_GREATER - [Obsolete("KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherStrength, HashAlgorithm and HashStrength properties are obsolete. Use NegotiatedCipherSuite instead.", DiagnosticId = "SYSLIB0058", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] -#endif - int KeyExchangeStrength { get; } - } -} diff --git a/src/Orleans.Connections.Security/Security/MemoryPoolExtensions.cs b/src/Orleans.Connections.Security/Security/MemoryPoolExtensions.cs deleted file mode 100644 index 65d1dc3b985..00000000000 --- a/src/Orleans.Connections.Security/Security/MemoryPoolExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Buffers; - -namespace Orleans.Connections.Security -{ - internal static class MemoryPoolExtensions - { - /// - /// Computes a minimum segment size - /// - /// - /// - public static int GetMinimumSegmentSize(this MemoryPool? pool) - { - if (pool == null) - { - return 4096; - } - - return Math.Min(4096, pool.MaxBufferSize); - } - - public static int GetMinimumAllocSize(this MemoryPool? pool) - { - // 1/2 of a segment - return pool.GetMinimumSegmentSize() / 2; - } - } -} diff --git a/src/Orleans.Connections.Security/Security/OrleansApplicationProtocol.cs b/src/Orleans.Connections.Security/Security/OrleansApplicationProtocol.cs deleted file mode 100644 index 14ab24ceeee..00000000000 --- a/src/Orleans.Connections.Security/Security/OrleansApplicationProtocol.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Net.Security; - -namespace Orleans.Connections.Security -{ - internal static class OrleansApplicationProtocol - { - public static readonly SslApplicationProtocol Orleans1 = new SslApplicationProtocol("Orleans1"); - } -} diff --git a/src/Orleans.Connections.Security/Security/RemoteCertificateMode.cs b/src/Orleans.Connections.Security/Security/RemoteCertificateMode.cs deleted file mode 100644 index 923ad969f2d..00000000000 --- a/src/Orleans.Connections.Security/Security/RemoteCertificateMode.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Orleans.Connections.Security -{ - /// - /// Describes the remote certificate requirements for a TLS connection. - /// - public enum RemoteCertificateMode - { - /// - /// A remote certificate is not required and will not be requested from remote endpoints. - /// - NoCertificate, - - /// - /// A remote certificate will be requested; however, authentication will not fail if a certificate is not provided by the remote endpoint. - /// - AllowCertificate, - - /// - /// A remote certificate will be requested, and the remote endpoint must provide a valid certificate for authentication. - /// - RequireCertificate - } -} diff --git a/src/Orleans.Connections.Security/Security/TlsClientAuthenticationOptions.cs b/src/Orleans.Connections.Security/Security/TlsClientAuthenticationOptions.cs deleted file mode 100644 index 262ac014f1f..00000000000 --- a/src/Orleans.Connections.Security/Security/TlsClientAuthenticationOptions.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Collections.Generic; -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; - -namespace Orleans.Connections.Security -{ - /// - /// Selects a local certificate for client authentication. - /// - /// The object requesting certificate selection. - /// The name of the target host. - /// The available local certificates. - /// The remote certificate, if available. - /// The certificate issuer names accepted by the remote endpoint. - /// The certificate to use for client authentication, or to omit a client certificate. - public delegate X509Certificate? ClientCertificateSelectionCallback(object sender, string targetHost, X509CertificateCollection localCertificates, X509Certificate? remoteCertificate, string[] acceptableIssuers); - - /// - /// Configures client authentication for an Orleans TLS connection. - /// - public class TlsClientAuthenticationOptions - { - internal SslClientAuthenticationOptions Value { get; } = new SslClientAuthenticationOptions - { - ApplicationProtocols = new List - { - OrleansApplicationProtocol.Orleans1 - } - }; - - /// - /// Gets or sets the callback which selects a local client certificate. - /// - public ClientCertificateSelectionCallback? LocalCertificateSelectionCallback - { - get => Value.LocalCertificateSelectionCallback is null ? null : new ClientCertificateSelectionCallback(Value.LocalCertificateSelectionCallback); - set - { -#if NET10_0_OR_GREATER - Value.LocalCertificateSelectionCallback = value is null ? null : new System.Net.Security.LocalCertificateSelectionCallback(value); -#else - Value.LocalCertificateSelectionCallback = value is null - ? null - : (sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers) => - value(sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers)!; -#endif - } - } - - /// - /// Gets or sets the certificates available for client authentication. - /// - public X509CertificateCollection? ClientCertificates - { - get => this.Value.ClientCertificates; - set => this.Value.ClientCertificates = value; - } - - /// - /// Gets or sets the TLS protocol versions permitted for authentication. - /// - public SslProtocols EnabledSslProtocols - { - get => this.Value.EnabledSslProtocols; - set => this.Value.EnabledSslProtocols = value; - } - - /// - /// Gets or sets the certificate revocation checking mode used during authentication. - /// - public X509RevocationMode CertificateRevocationCheckMode - { - get => this.Value.CertificateRevocationCheckMode; - set => this.Value.CertificateRevocationCheckMode = value; - } - - /// - /// Gets or sets the target host name used for server certificate validation and Server Name Indication (SNI). - /// - public string? TargetHost - { - get => this.Value.TargetHost; - set => this.Value.TargetHost = value; - } - - /// - /// Gets the underlying instance. - /// - public object SslClientAuthenticationOptions => this.Value; - } -} diff --git a/src/Orleans.Connections.Security/Security/TlsClientConnectionMiddleware.cs b/src/Orleans.Connections.Security/Security/TlsClientConnectionMiddleware.cs deleted file mode 100644 index e74868e7cf3..00000000000 --- a/src/Orleans.Connections.Security/Security/TlsClientConnectionMiddleware.cs +++ /dev/null @@ -1,264 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.IO.Pipelines; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.AspNetCore.Connections.Features; -using Microsoft.Extensions.Logging; -using Orleans.Runtime.Messaging; - -namespace Orleans.Connections.Security -{ - internal partial class TlsClientConnectionMiddleware : IConnectionMiddleware - { - private readonly TlsOptions _options; - private readonly ILogger? _logger; - private readonly X509Certificate2? _certificate; - private readonly Func? _certificateSelector; - - public TlsClientConnectionMiddleware(TlsOptions options, ILoggerFactory? loggerFactory) - { - if (options == null) - { - throw new ArgumentNullException(nameof(options)); - } - - // capture the certificate now so it can't be switched after validation - _certificate = ValidateCertificate(options.LocalCertificate, options.ClientCertificateMode); - _certificateSelector = options.LocalClientCertificateSelector; - - - _options = options; - _logger = loggerFactory?.CreateLogger(); - } - - public Task OnConnectionAsync(ConnectionContext context, ConnectionDelegate next) - { - return InnerOnConnectionAsync(context, next); - } - - private async Task InnerOnConnectionAsync(ConnectionContext context, ConnectionDelegate next) - { - var feature = new TlsConnectionFeature(); - context.Features.Set(feature); - context.Features.Set(feature); - - var memoryPool = context.Features.Get()?.MemoryPool; - - var inputPipeOptions = new StreamPipeReaderOptions - ( - pool: memoryPool, - bufferSize: memoryPool.GetMinimumSegmentSize(), - minimumReadSize: memoryPool.GetMinimumAllocSize(), - leaveOpen: true - ); - - var outputPipeOptions = new StreamPipeWriterOptions - ( - pool: memoryPool, - leaveOpen: true - ); - - TlsDuplexPipe? tlsDuplexPipe = null; - - if (_options.RemoteCertificateMode == RemoteCertificateMode.NoCertificate) - { - tlsDuplexPipe = new TlsDuplexPipe(context.Transport, inputPipeOptions, outputPipeOptions); - } - else - { - tlsDuplexPipe = new TlsDuplexPipe(context.Transport, inputPipeOptions, outputPipeOptions, s => new SslStream( - s, - leaveInnerStreamOpen: false, - userCertificateValidationCallback: (sender, certificate, chain, sslPolicyErrors) => - { - if (certificate == null) - { - return _options.RemoteCertificateMode != RemoteCertificateMode.RequireCertificate; - } - - if (_options.RemoteCertificateValidation == null) - { - if (sslPolicyErrors != SslPolicyErrors.None) - { - return false; - } - } - - var certificate2 = ConvertToX509Certificate2(certificate); - if (certificate2 == null) - { - return false; - } - - if (_options.RemoteCertificateValidation != null) - { - if (!_options.RemoteCertificateValidation(certificate2, chain, sslPolicyErrors)) - { - return false; - } - } - - return true; - })); - } - - var sslStream = tlsDuplexPipe.Stream; - - using (var cancellationTokenSource = _options.CreateHandshakeCancellationTokenSource()) - using (cancellationTokenSource.Token.UnsafeRegister(state => ((ConnectionContext)state!).Abort(), context)) - { - try - { - ClientCertificateSelectionCallback? selector = null; - if (_certificateSelector != null) - { - selector = (sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers) => - { - var cert = _certificateSelector(sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers); - if (cert != null) - { - EnsureCertificateIsAllowedForClientAuth(cert); - } - -#if NET10_0_OR_GREATER - return cert; -#else - return cert!; -#endif - }; - } - - var sslOptions = new TlsClientAuthenticationOptions - { - ClientCertificates = _certificate == null || _certificateSelector != null ? null : new X509CertificateCollection { _certificate }, - LocalCertificateSelectionCallback = selector, - EnabledSslProtocols = _options.SslProtocols, - }; - - _options.OnAuthenticateAsClient?.Invoke(context, sslOptions); - - await sslStream.AuthenticateAsClientAsync(sslOptions.Value, cancellationTokenSource.Token); - } - catch (OperationCanceledException ex) - { - if (_logger is { } logger) - { - LogWarningAuthenticationTimedOut(logger, ex); - } - await sslStream.DisposeAsync(); - return; - } - catch (Exception ex) - { - if (_logger is { } logger) - { - LogWarningAuthenticationFailed(logger, ex); - } - await sslStream.DisposeAsync(); - return; - } - } - - feature.ApplicationProtocol = sslStream.NegotiatedApplicationProtocol.Protocol; - - context.Features.Set(feature); - feature.LocalCertificate = ConvertToX509Certificate2(sslStream.LocalCertificate); - feature.RemoteCertificate = ConvertToX509Certificate2(sslStream.RemoteCertificate); - feature.NegotiatedCipherSuite = sslStream.NegotiatedCipherSuite; -#if NET10_0_OR_GREATER -#pragma warning disable SYSLIB0058 -#endif - feature.CipherAlgorithm = sslStream.CipherAlgorithm; - feature.CipherStrength = sslStream.CipherStrength; - feature.HashAlgorithm = sslStream.HashAlgorithm; - feature.HashStrength = sslStream.HashStrength; - feature.KeyExchangeAlgorithm = sslStream.KeyExchangeAlgorithm; - feature.KeyExchangeStrength = sslStream.KeyExchangeStrength; -#if NET10_0_OR_GREATER -#pragma warning restore SYSLIB0058 -#endif - feature.Protocol = sslStream.SslProtocol; - - var originalTransport = context.Transport; - - try - { - context.Transport = tlsDuplexPipe; - - // Disposing the stream will dispose the tlsDuplexPipe - await using (sslStream) - await using (tlsDuplexPipe) - { - await next(context); - // Dispose the inner stream (tlsDuplexPipe) before disposing the SslStream - // as the duplex pipe can hit an ODE as it still may be writing. - } - } - finally - { - // Restore the original so that it gets closed appropriately - context.Transport = originalTransport; - } - } - - private static X509Certificate2? ValidateCertificate(X509Certificate2? certificate, RemoteCertificateMode mode) - { - switch (mode) - { - case RemoteCertificateMode.NoCertificate: - return null; - case RemoteCertificateMode.AllowCertificate: - //if certificate exists but can not be used for client authentication. - if (certificate != null && CertificateLoader.IsCertificateAllowedForClientAuth(certificate)) - return certificate; - return null; - case RemoteCertificateMode.RequireCertificate: - EnsureCertificateIsAllowedForClientAuth(certificate); - return certificate; - default: - throw new ArgumentOutOfRangeException(nameof(mode), mode, null); - } - } - - protected static void EnsureCertificateIsAllowedForClientAuth([NotNull] X509Certificate2? certificate) - { - if (certificate is null) - { - throw new InvalidOperationException("No certificate provided for client authentication."); - } - - if (!CertificateLoader.IsCertificateAllowedForClientAuth(certificate)) - { - throw new InvalidOperationException($"Invalid client certificate for client authentication: {certificate.Thumbprint}"); - } - } - - private static X509Certificate2? ConvertToX509Certificate2(X509Certificate? certificate) - { - if (certificate is null) - { - return null; - } - - return certificate as X509Certificate2 ?? new X509Certificate2(certificate); - } - - [LoggerMessage( - EventId = 2, - Level = LogLevel.Warning, - Message = "Authentication timed out" - )] - private static partial void LogWarningAuthenticationTimedOut(ILogger logger, Exception exception); - - [LoggerMessage( - EventId = 1, - Level = LogLevel.Warning, - Message = "Authentication failed" - )] - private static partial void LogWarningAuthenticationFailed(ILogger logger, Exception exception); - } -} diff --git a/src/Orleans.Connections.Security/Security/TlsConnectionFeature.cs b/src/Orleans.Connections.Security/Security/TlsConnectionFeature.cs deleted file mode 100644 index 3d7ada32f5d..00000000000 --- a/src/Orleans.Connections.Security/Security/TlsConnectionFeature.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using System.Threading.Tasks; - -namespace Orleans.Connections.Security -{ - internal class TlsConnectionFeature : ITlsConnectionFeature, ITlsApplicationProtocolFeature, ITlsHandshakeFeature - { - public X509Certificate2? LocalCertificate { get; set; } - - public X509Certificate2? RemoteCertificate { get; set; } - - public ReadOnlyMemory ApplicationProtocol { get; set; } - - public SslProtocols Protocol { get; set; } - - public TlsCipherSuite? NegotiatedCipherSuite { get; set; } - - public string HostName { get; set; } = string.Empty; - -#if NET10_0_OR_GREATER -#pragma warning disable SYSLIB0058 -#endif - public CipherAlgorithmType CipherAlgorithm { get; set; } - - public int CipherStrength { get; set; } - - public HashAlgorithmType HashAlgorithm { get; set; } - - public int HashStrength { get; set; } - - public ExchangeAlgorithmType KeyExchangeAlgorithm { get; set; } - - public int KeyExchangeStrength { get; set; } -#if NET10_0_OR_GREATER -#pragma warning restore SYSLIB0058 -#endif - - public Task GetRemoteCertificateAsync(CancellationToken cancellationToken) - { - return Task.FromResult(RemoteCertificate); - } - } -} diff --git a/src/Orleans.Connections.Security/Security/TlsDuplexPipe.cs b/src/Orleans.Connections.Security/Security/TlsDuplexPipe.cs deleted file mode 100644 index fe9184b2bf9..00000000000 --- a/src/Orleans.Connections.Security/Security/TlsDuplexPipe.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.IO; -using System.IO.Pipelines; -using System.Net.Security; - -namespace Orleans.Connections.Security -{ - internal class TlsDuplexPipe : DuplexPipeStreamAdapter - { - public TlsDuplexPipe(IDuplexPipe transport, StreamPipeReaderOptions readerOptions, StreamPipeWriterOptions writerOptions) - : this(transport, readerOptions, writerOptions, s => new SslStream(s)) - { - - } - - public TlsDuplexPipe(IDuplexPipe transport, StreamPipeReaderOptions readerOptions, StreamPipeWriterOptions writerOptions, Func factory) : - base(transport, readerOptions, writerOptions, factory) - { - } - } -} diff --git a/src/Orleans.Connections.Security/Security/TlsOptions.cs b/src/Orleans.Connections.Security/Security/TlsOptions.cs deleted file mode 100644 index 430aa88f5c5..00000000000 --- a/src/Orleans.Connections.Security/Security/TlsOptions.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System; -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using Microsoft.AspNetCore.Connections; - -namespace Orleans.Connections.Security -{ - /// - /// Validates a remote certificate presented during TLS authentication. - /// - /// The remote certificate. - /// The certificate chain built during validation, if available. - /// The policy errors produced by the platform certificate validator. - /// to accept the certificate; otherwise, . - public delegate bool RemoteCertificateValidator(X509Certificate2 certificate, X509Chain? chain, SslPolicyErrors policyErrors); - - /// - /// Settings for how TLS connections are handled. - /// - public class TlsOptions - { - private static readonly TimeSpan MaximumFiniteHandshakeTimeout = TimeSpan.FromMilliseconds(uint.MaxValue - 1); - private TimeSpan _handshakeTimeout = TimeSpan.FromSeconds(10); - - /// - /// - /// Specifies the local certificate used to authenticate TLS connections. This is ignored on server if LocalCertificateSelector is set. - /// - /// - /// To omit client authentication set to null on client and set to or on server. - /// - /// - /// If the certificate has an Extended Key Usage extension, the usages must include Server Authentication (OID 1.3.6.1.5.5.7.3.1) for server and Client Authentication (OID 1.3.6.1.5.5.7.3.2) for client. - /// - /// - public X509Certificate2? LocalCertificate { get; set; } - - /// - /// - /// A callback that will be invoked to dynamically select a local server certificate. This is higher priority than LocalCertificate. - /// If SNI is not available then the name parameter will be null. - /// - /// - /// If the certificate has an Extended Key Usage extension, the usages must include Server Authentication (OID 1.3.6.1.5.5.7.3.1). - /// - /// - public Func? LocalServerCertificateSelector { get; set; } - - /// - /// - /// A callback that will be invoked to dynamically select a local client certificate. This is higher priority than LocalCertificate. - /// - /// - /// If the certificate has an Extended Key Usage extension, the usages must include Client Authentication (OID 1.3.6.1.5.5.7.3.2). - /// - /// - public Func? LocalClientCertificateSelector { get; set; } - - /// - /// Specifies the remote endpoint certificate requirements for a TLS connection. Defaults to . - /// - public RemoteCertificateMode RemoteCertificateMode { get; set; } = RemoteCertificateMode.RequireCertificate; - - /// - /// Specifies the client authentication certificate requirements for a TLS connection to Silo. Defaults to . - /// - public RemoteCertificateMode ClientCertificateMode { get; set; } = RemoteCertificateMode.AllowCertificate; - - /// - /// Specifies a callback for additional remote certificate validation that will be invoked during authentication. This will be ignored - /// if is called after this callback is set. - /// - public RemoteCertificateValidator? RemoteCertificateValidation { get; set; } - - /// - /// Specifies allowable SSL protocols. Defaults to and . - /// - public SslProtocols SslProtocols { get; set; } = SslProtocols.Tls13 | SslProtocols.Tls12; - - /// - /// Specifies whether the certificate revocation list is checked during authentication. - /// - public bool CheckCertificateRevocation { get; set; } - - /// - /// Overrides the current callback and allows any client certificate. - /// - public void AllowAnyRemoteCertificate() - { - RemoteCertificateValidation = (_, __, ___) => true; - } - - /// - /// Provides direct configuration of the on a per-connection basis. - /// This is called after all of the other settings have already been applied. - /// - public Action? OnAuthenticateAsServer { get; set; } - - /// - /// Provides direct configuration of the on a per-connection basis. - /// This is called after all of the other settings have already been applied. - /// Use this to set the target host name for SNI (Server Name Indication) via . - /// - public Action? OnAuthenticateAsClient { get; set; } - - /// - /// Specifies the maximum amount of time allowed for the TLS/SSL handshake. - /// This must be positive and no greater than 4,294,967,294 milliseconds (approximately 49.7 days), - /// or to disable the timeout. - /// - public TimeSpan HandshakeTimeout - { - get => _handshakeTimeout; - set - { - if ((value <= TimeSpan.Zero && value != Timeout.InfiniteTimeSpan) || value > MaximumFiniteHandshakeTimeout) - { - throw new ArgumentOutOfRangeException( - nameof(value), - $"{nameof(HandshakeTimeout)} must be positive and no greater than {MaximumFiniteHandshakeTimeout}, or {nameof(Timeout)}.{nameof(Timeout.InfiniteTimeSpan)}"); - } - - _handshakeTimeout = value; - } - } - - internal CancellationTokenSource CreateHandshakeCancellationTokenSource() => - _handshakeTimeout == Timeout.InfiniteTimeSpan - ? new CancellationTokenSource() - : new CancellationTokenSource(_handshakeTimeout); - } -} diff --git a/src/Orleans.Connections.Security/Security/TlsServerAuthenticationOptions.cs b/src/Orleans.Connections.Security/Security/TlsServerAuthenticationOptions.cs deleted file mode 100644 index 67262932666..00000000000 --- a/src/Orleans.Connections.Security/Security/TlsServerAuthenticationOptions.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Collections.Generic; -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; - -namespace Orleans.Connections.Security -{ - /// - /// Selects a server certificate using the host name supplied by the client. - /// - /// The object requesting certificate selection. - /// The host name supplied through Server Name Indication (SNI), if available. - /// The certificate to use for server authentication. - public delegate X509Certificate ServerCertificateSelectionCallback(object sender, string? hostName); - - /// - /// Configures server authentication for an Orleans TLS connection. - /// - public class TlsServerAuthenticationOptions - { - internal SslServerAuthenticationOptions Value { get; } = new SslServerAuthenticationOptions - { - ApplicationProtocols = new List - { - OrleansApplicationProtocol.Orleans1 - } - }; - - /// - /// Gets or sets the certificate used for server authentication. - /// - public X509Certificate? ServerCertificate - { - get => Value.ServerCertificate; - set => Value.ServerCertificate = value; - } - - /// - /// Gets or sets the callback which selects a server certificate for each connection. - /// - public ServerCertificateSelectionCallback? ServerCertificateSelectionCallback - { - get => Value.ServerCertificateSelectionCallback is null ? null : new ServerCertificateSelectionCallback(Value.ServerCertificateSelectionCallback); - set => Value.ServerCertificateSelectionCallback = value is null ? null : new System.Net.Security.ServerCertificateSelectionCallback(value); - } - - /// - /// Gets or sets a value indicating whether TLS authentication requests a client certificate. - /// - public bool ClientCertificateRequired - { - get => Value.ClientCertificateRequired; - set => Value.ClientCertificateRequired = value; - } - - /// - /// Gets or sets the TLS protocol versions permitted for authentication. - /// - public SslProtocols EnabledSslProtocols - { - get => Value.EnabledSslProtocols; - set => Value.EnabledSslProtocols = value; - } - - /// - /// Gets or sets the certificate revocation checking mode used during authentication. - /// - public X509RevocationMode CertificateRevocationCheckMode - { - get => Value.CertificateRevocationCheckMode; - set => Value.CertificateRevocationCheckMode = value; - } - - /// - /// Gets the underlying instance. - /// - public object SslServerAuthenticationOptions => this.Value; - } -} diff --git a/src/Orleans.Connections.Security/Security/TlsServerConnectionMiddleware.cs b/src/Orleans.Connections.Security/Security/TlsServerConnectionMiddleware.cs deleted file mode 100644 index a6826d9b6a0..00000000000 --- a/src/Orleans.Connections.Security/Security/TlsServerConnectionMiddleware.cs +++ /dev/null @@ -1,267 +0,0 @@ -using System; -using System.IO.Pipelines; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.AspNetCore.Connections.Features; -using Microsoft.Extensions.Logging; -using Orleans.Runtime.Messaging; - -namespace Orleans.Connections.Security -{ - internal partial class TlsServerConnectionMiddleware : IConnectionMiddleware - { - private readonly TlsOptions _options; - private readonly ILogger? _logger; - private readonly X509Certificate2? _certificate; - private readonly Func? _certificateSelector; - - public TlsServerConnectionMiddleware(TlsOptions options, ILoggerFactory? loggerFactory) - { - if (options == null) - { - throw new ArgumentNullException(nameof(options)); - } - - // capture the certificate now so it can't be switched after validation - _certificate = options.LocalCertificate; - _certificateSelector = options.LocalServerCertificateSelector; - if (_certificate == null && _certificateSelector == null) - { - throw new ArgumentException("Server certificate is required", nameof(options)); - } - - // If a selector is provided then ignore the cert, it may be a default cert. - if (_certificateSelector != null) - { - // SslStream doesn't allow both. - _certificate = null; - } - else - { - EnsureCertificateIsAllowedForServerAuth(_certificate!); - } - - _options = options; - _logger = loggerFactory?.CreateLogger(); - } - - public Task OnConnectionAsync(ConnectionContext context, ConnectionDelegate next) - { - return InnerOnConnectionAsync(context, next); - } - - private async Task InnerOnConnectionAsync(ConnectionContext context, ConnectionDelegate next) - { - bool certificateRequired; - var feature = new TlsConnectionFeature(); - context.Features.Set(feature); - context.Features.Set(feature); - - var memoryPool = context.Features.Get()?.MemoryPool; - - var inputPipeOptions = new StreamPipeReaderOptions - ( - pool: memoryPool, - bufferSize: memoryPool.GetMinimumSegmentSize(), - minimumReadSize: memoryPool.GetMinimumAllocSize(), - leaveOpen: true - ); - - var outputPipeOptions = new StreamPipeWriterOptions - ( - pool: memoryPool, - leaveOpen: true - ); - - TlsDuplexPipe? tlsDuplexPipe = null; - - if (_options.RemoteCertificateMode == RemoteCertificateMode.NoCertificate) - { - tlsDuplexPipe = new TlsDuplexPipe(context.Transport, inputPipeOptions, outputPipeOptions); - certificateRequired = false; - } - else - { - tlsDuplexPipe = new TlsDuplexPipe(context.Transport, inputPipeOptions, outputPipeOptions, s => new SslStream( - s, - leaveInnerStreamOpen: false, - userCertificateValidationCallback: (sender, certificate, chain, sslPolicyErrors) => - { - if (certificate == null) - { - return _options.RemoteCertificateMode != RemoteCertificateMode.RequireCertificate; - } - - if (_options.RemoteCertificateValidation == null) - { - if (sslPolicyErrors != SslPolicyErrors.None) - { - return false; - } - } - - var certificate2 = ConvertToX509Certificate2(certificate); - if (certificate2 == null) - { - return false; - } - - if (_options.RemoteCertificateValidation != null) - { - if (!_options.RemoteCertificateValidation(certificate2, chain, sslPolicyErrors)) - { - return false; - } - } - - return true; - })); - - certificateRequired = true; - } - - var sslStream = tlsDuplexPipe.Stream; - - using (var cancellationTokenSource = _options.CreateHandshakeCancellationTokenSource()) - using (cancellationTokenSource.Token.UnsafeRegister(state => ((ConnectionContext)state!).Abort(), context)) - { - try - { - // Adapt to the SslStream signature - ServerCertificateSelectionCallback? selector = null; - if (_certificateSelector != null) - { - selector = (sender, name) => - { - feature.HostName = name ?? string.Empty; - context.Features.Set(sslStream); - var cert = _certificateSelector!(context, name); - if (cert != null) - { - EnsureCertificateIsAllowedForServerAuth(cert); - } - - return cert!; - }; - } - else if (_certificate != null) - { - // Even with a fixed certificate, we still want to capture the SNI hostname - selector = (sender, name) => - { - feature.HostName = name ?? string.Empty; - return _certificate; - }; - } - - var sslOptions = new TlsServerAuthenticationOptions - { - ServerCertificate = selector == null ? _certificate : null, - ServerCertificateSelectionCallback = selector, - ClientCertificateRequired = certificateRequired, - EnabledSslProtocols = _options.SslProtocols, - CertificateRevocationCheckMode = _options.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, - }; - - _options.OnAuthenticateAsServer?.Invoke(context, sslOptions); - - await sslStream.AuthenticateAsServerAsync(sslOptions.Value, cancellationTokenSource.Token); - } - catch (OperationCanceledException ex) - { - if (_logger is { } logger) - { - LogWarningAuthenticationTimedOut(logger, ex); - } - await sslStream.DisposeAsync(); - return; - } - catch (Exception ex) - { - if (_logger is { } logger) - { - LogWarningAuthenticationFailed(logger, ex); - } - await sslStream.DisposeAsync(); - return; - } - } - - feature.ApplicationProtocol = sslStream.NegotiatedApplicationProtocol.Protocol; - - context.Features.Set(feature); - feature.LocalCertificate = ConvertToX509Certificate2(sslStream.LocalCertificate); - feature.RemoteCertificate = ConvertToX509Certificate2(sslStream.RemoteCertificate); - feature.NegotiatedCipherSuite = sslStream.NegotiatedCipherSuite; -#if NET10_0_OR_GREATER -#pragma warning disable SYSLIB0058 -#endif - feature.CipherAlgorithm = sslStream.CipherAlgorithm; - feature.CipherStrength = sslStream.CipherStrength; - feature.HashAlgorithm = sslStream.HashAlgorithm; - feature.HashStrength = sslStream.HashStrength; - feature.KeyExchangeAlgorithm = sslStream.KeyExchangeAlgorithm; - feature.KeyExchangeStrength = sslStream.KeyExchangeStrength; -#if NET10_0_OR_GREATER -#pragma warning restore SYSLIB0058 -#endif - feature.Protocol = sslStream.SslProtocol; - - var originalTransport = context.Transport; - - try - { - context.Transport = tlsDuplexPipe; - - // Disposing the stream will dispose the TlsDuplexPipe - await using (sslStream) - await using (tlsDuplexPipe) - { - await next(context); - // Dispose the inner stream (TlsDuplexPipe) before disposing the SslStream - // as the duplex pipe can hit an ODE as it still may be writing. - } - } - finally - { - // Restore the original so that it gets closed appropriately - context.Transport = originalTransport; - } - } - - protected static void EnsureCertificateIsAllowedForServerAuth(X509Certificate2 certificate) - { - if (!CertificateLoader.IsCertificateAllowedForServerAuth(certificate)) - { - throw new InvalidOperationException($"Invalid server certificate for server authentication: {certificate.Thumbprint}"); - } - } - - private static X509Certificate2? ConvertToX509Certificate2(X509Certificate? certificate) - { - if (certificate is null) - { - return null; - } - - return certificate as X509Certificate2 ?? new X509Certificate2(certificate); - } - - [LoggerMessage( - EventId = 2, - Level = LogLevel.Warning, - Message = "Authentication timed out" - )] - private static partial void LogWarningAuthenticationTimedOut(ILogger logger, Exception exception); - - [LoggerMessage( - EventId = 1, - Level = LogLevel.Warning, - Message = "Authentication failed" - )] - private static partial void LogWarningAuthenticationFailed(ILogger logger, Exception exception); - } -} diff --git a/src/Orleans.Core/CompatibilitySuppressions.xml b/src/Orleans.Core/CompatibilitySuppressions.xml new file mode 100644 index 00000000000..c125fd152ed --- /dev/null +++ b/src/Orleans.Core/CompatibilitySuppressions.xml @@ -0,0 +1,116 @@ + + + + + CP0001 + T:Orleans.ConnectionMiddlewareExtensions + lib/net10.0/Orleans.Core.dll + lib/net10.0/Orleans.Core.dll + true + + + CP0001 + T:Orleans.Configuration.ClientConnectionOptions + lib/net10.0/Orleans.Core.dll + lib/net10.0/Orleans.Core.dll + true + + + CP0001 + T:Orleans.Networking.Shared.SocketConnectionException + lib/net10.0/Orleans.Core.dll + lib/net10.0/Orleans.Core.dll + true + + + CP0001 + T:Orleans.Networking.Shared.SocketConnectionOptions + lib/net10.0/Orleans.Core.dll + lib/net10.0/Orleans.Core.dll + true + + + CP0001 + T:Orleans.Runtime.Messaging.ConnectionFrameHelper + lib/net10.0/Orleans.Core.dll + lib/net10.0/Orleans.Core.dll + true + + + CP0001 + T:Orleans.Runtime.Messaging.IConnectionMiddleware + lib/net10.0/Orleans.Core.dll + lib/net10.0/Orleans.Core.dll + true + + + CP0001 + T:OrleansCodeGen.Orleans.Networking.Shared.Codec_SocketConnectionException + lib/net10.0/Orleans.Core.dll + lib/net10.0/Orleans.Core.dll + true + + + CP0001 + T:OrleansCodeGen.Orleans.Networking.Shared.Copier_SocketConnectionException + lib/net10.0/Orleans.Core.dll + lib/net10.0/Orleans.Core.dll + true + + + CP0001 + T:Orleans.ConnectionMiddlewareExtensions + lib/net8.0/Orleans.Core.dll + lib/net8.0/Orleans.Core.dll + true + + + CP0001 + T:Orleans.Configuration.ClientConnectionOptions + lib/net8.0/Orleans.Core.dll + lib/net8.0/Orleans.Core.dll + true + + + CP0001 + T:Orleans.Networking.Shared.SocketConnectionException + lib/net8.0/Orleans.Core.dll + lib/net8.0/Orleans.Core.dll + true + + + CP0001 + T:Orleans.Networking.Shared.SocketConnectionOptions + lib/net8.0/Orleans.Core.dll + lib/net8.0/Orleans.Core.dll + true + + + CP0001 + T:Orleans.Runtime.Messaging.ConnectionFrameHelper + lib/net8.0/Orleans.Core.dll + lib/net8.0/Orleans.Core.dll + true + + + CP0001 + T:Orleans.Runtime.Messaging.IConnectionMiddleware + lib/net8.0/Orleans.Core.dll + lib/net8.0/Orleans.Core.dll + true + + + CP0001 + T:OrleansCodeGen.Orleans.Networking.Shared.Codec_SocketConnectionException + lib/net8.0/Orleans.Core.dll + lib/net8.0/Orleans.Core.dll + true + + + CP0001 + T:OrleansCodeGen.Orleans.Networking.Shared.Copier_SocketConnectionException + lib/net8.0/Orleans.Core.dll + lib/net8.0/Orleans.Core.dll + true + + diff --git a/src/Orleans.Core/Core/DefaultClientServices.cs b/src/Orleans.Core/Core/DefaultClientServices.cs index 2a5b80e30d0..1c512ea1b20 100644 --- a/src/Orleans.Core/Core/DefaultClientServices.cs +++ b/src/Orleans.Core/Core/DefaultClientServices.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Connections; +using System; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -7,11 +7,12 @@ using Orleans.Configuration; using Orleans.Configuration.Internal; using Orleans.Configuration.Validators; +using Orleans.Connections; +using Orleans.Connections.Transport; +using Orleans.Connections.Transport.Sockets; using Orleans.GrainReferences; -using Orleans.Hosting; using Orleans.Messaging; using Orleans.Metadata; -using Orleans.Networking.Shared; using Orleans.Placement.Repartitioning; using Orleans.Providers; using Orleans.Runtime.Messaging; @@ -19,6 +20,7 @@ using Orleans.Serialization; using Orleans.Serialization.Cloning; using Orleans.Serialization.Serializers; +using Orleans.Serialization.Session; using Orleans.Statistics; namespace Orleans @@ -49,9 +51,6 @@ public static void AddDefaultServices(IClientBuilder builder) services.AddOptions(); services.AddMetrics(); services.TryAddSingleton(TimeProvider.System); - - // Catch-all keyed TimeProvider: consumers resolve their area's clock via [FromKeyedServices(TimeProviderNames.X)]; - // unless an area has been explicitly overridden, this fallback supplies the unkeyed default provider. services.TryAddKeyedSingleton(KeyedService.AnyKey, static (sp, _) => sp.GetRequiredService()); services.TryAddSingleton(); services.TryAddSingleton(); @@ -120,12 +119,10 @@ public static void AddDefaultServices(IClientBuilder builder) services.AddTransient(); services.AddTransient(); - // TODO: abstract or move into some options. - services.AddSingleton(); - services.AddSingleton(); - // Networking + services.AddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -134,10 +131,6 @@ public static void AddDefaultServices(IClientBuilder builder) sp.GetRequiredService(), ct => sp.GetRequiredService().StopObserverInvocationsAsync().WaitAsync(ct))); - services.AddKeyedSingleton( - ClientOutboundConnectionFactory.ServicesKey, - (sp, key) => ActivatorUtilities.CreateInstance(sp)); - services.AddSerializer(); services.AddSingleton(); services.AddSingleton(); @@ -146,13 +139,17 @@ public static void AddDefaultServices(IClientBuilder builder) services.AddSingleton, ConfigureOrleansJsonSerializerOptions>(); services.AddSingleton(); - services.TryAddTransient(sp => ActivatorUtilities.CreateInstance( - sp, - sp.GetRequiredService>().Value)); + services.TryAddSingleton(sp => + { + var sessionPool = sp.GetRequiredService(); + var options = sp.GetRequiredService>(); + return () => new MessageSerializer(sessionPool, options.Value); + }); services.TryAddSingleton(); - services.TryAddSingleton(sp => sp.GetRequiredService().MessageCenter!); + services.AddSingleton(sp => sp.GetRequiredService().MessageCenter!); services.TryAddFromExisting(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // Type metadata @@ -174,6 +171,7 @@ public static void AddDefaultServices(IClientBuilder builder) services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); ApplyConfiguration(builder); } @@ -279,7 +277,7 @@ private class AllowOrleansTypes : ITypeNameFilter } /// - /// A marker type used to determine + /// A marker type used to determine whether the default services have been added. /// private class ServicesAdded { } } diff --git a/src/Orleans.Connections.Security/Hosting/HostingExtensions.IClientBuilder.cs b/src/Orleans.Core/Hosting/ClientTlsHostingExtensions.IClientBuilder.cs similarity index 55% rename from src/Orleans.Connections.Security/Hosting/HostingExtensions.IClientBuilder.cs rename to src/Orleans.Core/Hosting/ClientTlsHostingExtensions.IClientBuilder.cs index 894050086c1..83cca7c8199 100644 --- a/src/Orleans.Connections.Security/Hosting/HostingExtensions.IClientBuilder.cs +++ b/src/Orleans.Core/Hosting/ClientTlsHostingExtensions.IClientBuilder.cs @@ -1,14 +1,17 @@ using System; using System.Security.Cryptography.X509Certificates; -using Orleans.Configuration; -using Orleans.Connections.Security; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Orleans.Connections.Transport; +using Orleans.Connections.Transport.Security; +using Orleans.Runtime; namespace Orleans.Hosting { /// - /// Provides extension methods for configuring TLS on Orleans clients and silos. + /// Provides extension methods for configuring TLS on Orleans clients. /// - public static partial class OrleansConnectionSecurityHostingExtensions + public static partial class ClientTlsHostingExtensions { /// /// Configures TLS. @@ -28,10 +31,7 @@ public static IClientBuilder UseTls( StoreLocation location, Action configureOptions) { - if (configureOptions is null) - { - throw new ArgumentNullException(nameof(configureOptions)); - } + ArgumentNullException.ThrowIfNull(configureOptions); return builder.UseTls( CertificateLoader.LoadFromStoreCert(subject, storeName.ToString(), location, allowInvalid, server: false), @@ -50,19 +50,12 @@ public static IClientBuilder UseTls( X509Certificate2 certificate, Action configureOptions) { - if (certificate is null) - { - throw new ArgumentNullException(nameof(certificate)); - } - - if (configureOptions is null) - { - throw new ArgumentNullException(nameof(configureOptions)); - } + ArgumentNullException.ThrowIfNull(certificate); + ArgumentNullException.ThrowIfNull(configureOptions); if (!certificate.HasPrivateKey) { - TlsConnectionBuilderExtensions.ThrowNoPrivateKey(certificate, nameof(certificate)); + throw new ArgumentException($"Certificate {certificate.ToString(verbose: true)} does not contain a private key", nameof(certificate)); } return builder.UseTls(options => @@ -82,14 +75,11 @@ public static IClientBuilder UseTls( this IClientBuilder builder, X509Certificate2 certificate) { - if (certificate is null) - { - throw new ArgumentNullException(nameof(certificate)); - } + ArgumentNullException.ThrowIfNull(certificate); if (!certificate.HasPrivateKey) { - TlsConnectionBuilderExtensions.ThrowNoPrivateKey(certificate, nameof(certificate)); + throw new ArgumentException($"Certificate {certificate.ToString(verbose: true)} does not contain a private key", nameof(certificate)); } return builder.UseTls(options => @@ -108,30 +98,39 @@ public static IClientBuilder UseTls( this IClientBuilder builder, Action configureOptions) { - if (configureOptions is null) - { - throw new ArgumentNullException(nameof(configureOptions)); - } + ArgumentNullException.ThrowIfNull(configureOptions); - var options = new TlsOptions(); - configureOptions(options); - if (options.LocalCertificate is null && options.ClientCertificateMode == RemoteCertificateMode.RequireCertificate) - { - throw new InvalidOperationException("No certificate specified"); - } + builder.Configure(configureOptions); + builder.Services.AddSingleton(sp => new TlsOptionsValidator(sp.GetRequiredService>().Value)); + builder.Services.AddSingleton(); + return builder; + } - if (options.LocalCertificate is X509Certificate2 certificate && !certificate.HasPrivateKey) + internal sealed class TlsOptionsValidator(TlsOptions options, bool isServer = false) : IConfigurationValidator + { + public void ValidateConfiguration() { - TlsConnectionBuilderExtensions.ThrowNoPrivateKey(certificate, $"{nameof(TlsOptions)}.{nameof(TlsOptions.LocalCertificate)}"); - } + if (isServer && options.LocalCertificate is null && options.LocalServerCertificateSelector is null) + { + throw new OrleansConfigurationException("No server certificate or server certificate selector specified"); + } - return builder.Configure(connectionOptions => - { - connectionOptions.ConfigureConnection(connectionBuilder => + if (!isServer + && options.LocalCertificate is null + && options.LocalClientCertificateSelector is null + && options.ClientCertificateMode == RemoteCertificateMode.RequireCertificate) { - connectionBuilder.UseClientTls(options); - }); - }); + throw new OrleansConfigurationException("No client certificate or client certificate selector specified"); + } + + var usesLocalCertificate = isServer + ? options.LocalServerCertificateSelector is null + : options.LocalClientCertificateSelector is null; + if (usesLocalCertificate && options.LocalCertificate is X509Certificate2 certificate && !certificate.HasPrivateKey) + { + throw new OrleansConfigurationException($"Certificate {certificate.ToString(verbose: true)} does not contain a private key"); + } + } } } } diff --git a/src/Orleans.Core/Messaging/CachingSiloAddressCodec.cs b/src/Orleans.Core/Messaging/CachingSiloAddressCodec.cs index ffec0011fa6..1c0a84c678a 100644 --- a/src/Orleans.Core/Messaging/CachingSiloAddressCodec.cs +++ b/src/Orleans.Core/Messaging/CachingSiloAddressCodec.cs @@ -32,9 +32,7 @@ public CachingSiloAddressCodec() public SiloAddress? ReadRaw(ref Reader reader) { - var currentTimestamp = Environment.TickCount64; - - SiloAddress? result = null; + SiloAddress result; byte[]? payloadArray = default; var length = (int)reader.ReadVarUInt32(); if (length == 0) @@ -47,11 +45,11 @@ public CachingSiloAddressCodec() payloadSpan = payloadArray = reader.ReadBytes((uint)length); } - // This payload only contains primitive values, so no serializer session is required. - var innerReader = Reader.Create(payloadSpan, null!); + var innerReader = Reader.Create(payloadSpan, session: null!); var hashCode = innerReader.ReadInt32(); ref var cacheEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(_cache, hashCode, out var exists); + var currentTimestamp = Environment.TickCount64; if (exists && payloadSpan.SequenceEqual(cacheEntry.Encoded)) { result = cacheEntry.Value; @@ -132,7 +130,7 @@ public void WriteRaw(ref Writer writer, SiloAddres return; } - var innerWriter = Writer.Create(new PooledBuffer(), null!); + var innerWriter = Writer.Create(new PooledBuffer(), session: null!); innerWriter.WriteInt32(value.GetConsistentHashCode()); WriteSiloAddressInner(ref innerWriter, value); innerWriter.Commit(); diff --git a/src/Orleans.Core/Messaging/ClientMessageCenter.cs b/src/Orleans.Core/Messaging/ClientMessageCenter.cs index b18a3ad6369..6982941d734 100644 --- a/src/Orleans.Core/Messaging/ClientMessageCenter.cs +++ b/src/Orleans.Core/Messaging/ClientMessageCenter.cs @@ -177,6 +177,7 @@ public void SendMessage(Message msg) if (!Running) { LogNotRunning(msg); + msg.Dispose(); return; } @@ -366,9 +367,17 @@ public void RegisterLocalMessageHandler(Action handler) this.messageHandler = handler; } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the rejection message is transferred to the local message dispatcher.")] public void RejectMessage(Message msg, string reason, Exception? exc = null) { - if (!Running) return; + if (!Running) + { + msg.Dispose(); + return; + } if (msg.Direction != Message.Directions.Request) { @@ -381,6 +390,8 @@ public void RejectMessage(Message msg, string reason, Exception? exc = null) var error = this.messageFactory.CreateRejectionResponse(msg, Message.RejectionTypes.Unrecoverable, reason, exc); DispatchLocalMessage(error); } + + msg.Dispose(); } internal void OnGatewayConnectionOpen() diff --git a/src/Orleans.Core/Messaging/Message.cs b/src/Orleans.Core/Messaging/Message.cs index ed3d33efa79..64a2b2cca3d 100644 --- a/src/Orleans.Core/Messaging/Message.cs +++ b/src/Orleans.Core/Messaging/Message.cs @@ -3,11 +3,13 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; +using Orleans.Runtime.Messaging; +using Orleans.Serialization.Invocation; namespace Orleans.Runtime { [Id(101)] - internal sealed class Message : ISpanFormattable + internal sealed class Message : ISpanFormattable, IDisposable { public const int LENGTH_HEADER_SIZE = 8; public const int LENGTH_META_HEADER = 4; @@ -18,7 +20,119 @@ internal sealed class Message : ISpanFormattable public CoarseStopwatch _timeToExpiry; - public object? BodyObject { get; set; } + internal object? _bodyObject; + + public object? BodyObject + { + get + { + if (_bodyObject is MessageReadRequest readRequest) + { + DeserializeRequestBody(readRequest); + } + + return _bodyObject; + } + + set + { + (_bodyObject as MessageReadRequest)?.Reset(); + _bodyObject = value; + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The serializer is borrowed from and returned to MessageHandlerShared.")] + private object? GetBodyObjectSafe() + { + if (_bodyObject is MessageReadRequest readRequest) + { + var responseType = Result; + _bodyObject = null; + MessageSerializer? messageSerializer = null; + try + { + messageSerializer = readRequest.Shared.GetMessageSerializer(); + messageSerializer.ReadBodyObject(this, readRequest); + } + catch (Exception exception) + { + _bodyObject = readRequest; + Result = responseType; + return $"Unable to deserialize message body: {exception.Message}"; + } + finally + { + if (messageSerializer is not null) + { + readRequest.Shared.Return(messageSerializer); + } + + if (!ReferenceEquals(_bodyObject, readRequest)) + { + readRequest.Reset(); + } + } + } + + return _bodyObject; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The serializer is borrowed from and returned to MessageHandlerShared.")] + private void DeserializeRequestBody(MessageReadRequest readRequest) + { + _bodyObject = null; + MessageSerializer? messageSerializer = null; + try + { + messageSerializer = readRequest.Shared.GetMessageSerializer(); + messageSerializer.ReadBodyObject(this, readRequest); + } + catch (Exception exception) when (Direction == Directions.Response) + { + _bodyObject = Response.FromException(exception); + } + finally + { + if (messageSerializer is not null) + { + readRequest.Shared.Return(messageSerializer); + } + + readRequest.Reset(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void SetMessageReadRequest(MessageReadRequest request) + { + if (_bodyObject is MessageReadRequest current && !ReferenceEquals(current, request)) + { + current.Reset(); + } + + _bodyObject = request; + } + + internal void ReleaseBodyBuffer() + { + if (_bodyObject is MessageReadRequest readRequest) + { + _bodyObject = null; + readRequest.Reset(); + } + } + + public void Dispose() + { + (_bodyObject as MessageReadRequest)?.Reset(); + _bodyObject = null; + } public PackedHeaders _headers; public CorrelationId _id; @@ -223,6 +337,12 @@ public TimeSpan? TimeToLive } } + internal void SetTimeToLive(Message other) + { + _headers.SetFlag(MessageFlags.HasTimeToLive, other._headers.HasFlag(MessageFlags.HasTimeToLive)); + _timeToExpiry = other._timeToExpiry; + } + internal long GetTimeToLiveMilliseconds() => -_timeToExpiry.ElapsedMilliseconds; internal void SetTimeToLiveMilliseconds(long milliseconds) @@ -340,11 +460,12 @@ bool ISpanFormattable.TryFormat(Span dst, out int charsWritten, ReadOnlySp if (IsReadOnly && !Append(ref dst, "ReadOnly ")) goto grow; if (IsAlwaysInterleave && !Append(ref dst, "IsAlwaysInterleave ")) goto grow; + var bodyObject = GetBodyObjectSafe(); if (Direction == Directions.Response) { switch (Result) { - case ResponseTypes.Rejection when BodyObject is RejectionResponse rejection: + case ResponseTypes.Rejection when bodyObject is RejectionResponse rejection: if (!dst.TryWrite($"{rejection.RejectionType} Rejection (info: {rejection.RejectionInfo}) ", out len)) goto grow; dst = dst[len..]; break; @@ -362,7 +483,7 @@ bool ISpanFormattable.TryFormat(Span dst, out int charsWritten, ReadOnlySp if (!dst.TryWrite($"{Direction} [{SendingSilo} {SendingGrain}]->[{TargetSilo} {TargetGrain}]", out len)) goto grow; dst = dst[len..]; - if (BodyObject is { } request) + if (bodyObject is { } request) { if (!dst.TryWrite($" {request}", out len)) goto grow; dst = dst[len..]; diff --git a/src/Orleans.Core/Messaging/MessageFactory.cs b/src/Orleans.Core/Messaging/MessageFactory.cs index c4ffff9223d..d56e027b7b9 100644 --- a/src/Orleans.Core/Messaging/MessageFactory.cs +++ b/src/Orleans.Core/Messaging/MessageFactory.cs @@ -63,10 +63,10 @@ public Message CreateResponseMessage(Message request) SendingSilo = request.TargetSilo, SendingGrain = request.TargetGrain, CacheInvalidationHeader = request.CacheInvalidationHeader, - TimeToLive = request.TimeToLive, RequestContextData = RequestContextExtensions.Export(_deepCopier), }; + response.SetTimeToLive(request); return response; } diff --git a/src/Orleans.Core/Messaging/MessageSerializer.cs b/src/Orleans.Core/Messaging/MessageSerializer.cs index cd335ab0e0f..38c13fe8f4c 100644 --- a/src/Orleans.Core/Messaging/MessageSerializer.cs +++ b/src/Orleans.Core/Messaging/MessageSerializer.cs @@ -1,14 +1,10 @@ using System; using System.Buffers; -using System.Buffers.Binary; using System.Collections.Generic; using System.Diagnostics; -using System.IO.Pipelines; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; using System.Text; using Orleans.Configuration; -using Orleans.Networking.Shared; using Orleans.Serialization.Buffers; using Orleans.Serialization.Codecs; using Orleans.Serialization.GeneratedCodeHelpers; @@ -26,7 +22,7 @@ internal sealed class MessageSerializer : IDisposable private const int MaxRequestContextInitialCapacity = 1024; private readonly Dictionary _rawResponseCodecs = []; private readonly CodecProvider _codecProvider; - private readonly IFieldCodec _grainAddressCacheUpdateCodec; + private readonly IFieldCodec _activationAddressCodec; private readonly CachingSiloAddressCodec _readerSiloAddressCodec = new(); private readonly CachingSiloAddressCodec _writerSiloAddressCodec = new(); private readonly CachingIdSpanCodec _idSpanCodec = new(); @@ -34,13 +30,10 @@ internal sealed class MessageSerializer : IDisposable private readonly SerializerSession _deserializationSession; private readonly int _maxHeaderLength; private readonly int _maxBodyLength; - private readonly DictionaryCodec _requestContextCodec; - private readonly PrefixingBufferWriter _bufferWriter; private bool _disposed; public MessageSerializer( SerializerSessionPool sessionPool, - SharedMemoryPool memoryPool, MessagingOptions options) { _serializationSession = sessionPool.GetSession(); @@ -48,9 +41,7 @@ public MessageSerializer( _maxHeaderLength = options.MaxMessageHeaderSize; _maxBodyLength = options.MaxMessageBodySize; _codecProvider = sessionPool.CodecProvider; - _requestContextCodec = OrleansGeneratedCodeHelper.GetService>(this, sessionPool.CodecProvider); - _grainAddressCacheUpdateCodec = OrleansGeneratedCodeHelper.GetService>(this, sessionPool.CodecProvider); - _bufferWriter = new(FramingLength, MessageSizeHint, memoryPool.Pool); + _activationAddressCodec = OrleansGeneratedCodeHelper.GetService>(this, sessionPool.CodecProvider); } public void Dispose() @@ -61,103 +52,54 @@ public void Dispose() } _disposed = true; - _bufferWriter.Dispose(); _serializationSession.Dispose(); _deserializationSession.Dispose(); } - public (int RequiredBytes, int HeaderLength, int BodyLength) TryRead(ref ReadOnlySequence input, out Message? message) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void ReadHeaders(MessageReadRequest readRequest, out Message message) { - if (input.Length < FramingLength) + try { - message = default; - return (FramingLength, 0, 0); + // Build message + message = new(); + var headersReader = Reader.Create(readRequest._headers, _deserializationSession); + DeserializeHeaders(ref headersReader, message); + readRequest._originalResponseType = message.Result; } - - Span lengthBytes = stackalloc byte[FramingLength]; - input.Slice(input.Start, FramingLength).CopyTo(lengthBytes); - var headerLength = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes); - var bodyLength = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes[4..]); - - // Check lengths - ThrowIfLengthsInvalid(headerLength, bodyLength); - - var requiredBytes = FramingLength + headerLength + bodyLength; - if (input.Length < requiredBytes) + finally { - message = default; - return (requiredBytes, 0, 0); + readRequest.ReleaseHeaders(); + _deserializationSession.Reset(); } + } + internal void ReadBodyObject(Message message, MessageReadRequest readRequest) + { try { - // Decode header - var header = input.Slice(FramingLength, headerLength); - - // Decode body - int bodyOffset = FramingLength + headerLength; - var body = input.Slice(bodyOffset, bodyLength); + var reader = Reader.Create(readRequest.Body, _deserializationSession); + var field = reader.ReadFieldHeader(); - // Build message - message = new(); - if (header.IsSingleSegment) + if (message.Result == ResponseTypes.Success) { - var headersReader = Reader.Create(header.First.Span, _deserializationSession); - Deserialize(ref headersReader, message); + message.Result = ResponseTypes.None; // reset raw response indicator + if (!_rawResponseCodecs.TryGetValue(field.FieldType!, out var rawCodec)) + rawCodec = GetRawCodec(field.FieldType!); + message._bodyObject = rawCodec.ReadRaw(ref reader, ref field); } else { - var headersReader = Reader.Create(header, _deserializationSession); - Deserialize(ref headersReader, message); + var bodyCodec = _codecProvider.GetCodec(field.FieldType!); + message._bodyObject = bodyCodec.ReadValue(ref reader, field); } - - if (bodyLength != 0) - { - _deserializationSession.PartialReset(); - - // Body deserialization is more likely to fail than header deserialization. - // Separating the two allows for these kinds of errors to be propagated back to the caller. - if (body.IsSingleSegment) - { - var reader = Reader.Create(body.First.Span, _deserializationSession); - ReadBodyObject(message, ref reader); - } - else - { - var reader = Reader.Create(body, _deserializationSession); - ReadBodyObject(message, ref reader); - } - } - - return (0, headerLength, bodyLength); } finally { - input = input.Slice(requiredBytes); _deserializationSession.Reset(); } } - private void ReadBodyObject(Message message, ref Reader reader) - { - var field = reader.ReadFieldHeader(); - // A non-empty message body always carries its concrete field type. - var fieldType = field.FieldType!; - - if (message.Result == ResponseTypes.Success) - { - message.Result = ResponseTypes.None; // reset raw response indicator - if (!_rawResponseCodecs.TryGetValue(fieldType, out var rawCodec)) - rawCodec = GetRawCodec(fieldType); - message.BodyObject = rawCodec.ReadRaw(ref reader, ref field); - } - else - { - var bodyCodec = _codecProvider.GetCodec(fieldType); - message.BodyObject = bodyCodec.ReadValue(ref reader, field); - } - } - private ResponseCodec GetRawCodec(Type fieldType) { var rawCodec = (ResponseCodec)_codecProvider.GetCodec(typeof(Response<>).MakeGenericType(fieldType)); @@ -165,14 +107,20 @@ private ResponseCodec GetRawCodec(Type fieldType) return rawCodec; } - public (int HeaderLength, int BodyLength) Write(PipeWriter writer, Message message) + public (int HeaderLength, int BodyLength) Write(ArcBufferWriter buffer, Message message) { var headers = message.Headers; IFieldCodec? bodyCodec = null; ResponseCodec? rawCodec = null; - if (message.BodyObject is not null) + var bodyObject = message._bodyObject; + var readRequest = bodyObject as MessageReadRequest; + if (readRequest is not null) + { + headers.ResponseType = readRequest._originalResponseType; + } + else if (bodyObject is not null) { - bodyCodec = _codecProvider.GetCodec(message.BodyObject.GetType()); + bodyCodec = _codecProvider.GetCodec(bodyObject.GetType()); if (headers.ResponseType is ResponseTypes.None && bodyCodec is ResponseCodec responseCodec) { rawCodec = responseCodec; @@ -184,59 +132,54 @@ private ResponseCodec GetRawCodec(Type fieldType) try { - var bufferWriter = _bufferWriter; - bufferWriter.Init(writer); - - var innerWriter = Writer.Create(new MessageBufferWriter(bufferWriter), _serializationSession); - Serialize(ref innerWriter, message, headers); - innerWriter.Commit(); - - var headerLength = bufferWriter.CommittedBytes; - - _serializationSession.PartialReset(); + var writer = Writer.Create(buffer, _serializationSession); + SerializeHeaders(ref writer, message, headers); + writer.Commit(); + var headerLength = writer.Position; + _serializationSession.Reset(); - if (bodyCodec is not null) + var bodyLength = 0; + if (readRequest is not null) { - innerWriter = Writer.Create(new MessageBufferWriter(bufferWriter), _serializationSession); - if (rawCodec != null) rawCodec.WriteRaw(ref innerWriter, message.BodyObject!); - else bodyCodec.WriteField(ref innerWriter, 0, null, message.BodyObject); - innerWriter.Commit(); + bodyLength = readRequest.BodyLength; + readRequest.Body.CopyTo(buffer); } - - var bodyLength = bufferWriter.CommittedBytes - headerLength; - - // Before completing, check lengths - ThrowIfLengthsInvalid(headerLength, bodyLength); - - // Write length prefixes, first header length then body length. - var lengthFields = (headerLength, bodyLength); - if (!BitConverter.IsLittleEndian) + else if (bodyCodec is not null) { - lengthFields.headerLength = BinaryPrimitives.ReverseEndianness(headerLength); - lengthFields.bodyLength = BinaryPrimitives.ReverseEndianness(bodyLength); + Debug.Assert(bodyObject is not null); + writer = Writer.Create(buffer, _serializationSession); + if (rawCodec != null) rawCodec.WriteRaw(ref writer, bodyObject); + else bodyCodec.WriteField(ref writer, 0, null, bodyObject); + writer.Commit(); + bodyLength = writer.Position; } - bufferWriter.Complete(MemoryMarshal.AsBytes(new Span<(int, int)>(ref lengthFields))); + + // Before completing, check lengths + ValidateFrameLengths(headerLength, bodyLength); return (headerLength, bodyLength); } finally { - _bufferWriter.Reset(); _serializationSession.Reset(); } } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void ThrowIfLengthsInvalid(int headerLength, int bodyLength) + internal void ValidateFrameLengths(int headerLength, int bodyLength) { if (headerLength <= 0 || headerLength > _maxHeaderLength) ThrowInvalidHeaderLength(headerLength); if ((uint)bodyLength > (uint)_maxBodyLength) ThrowInvalidBodyLength(bodyLength); + if ((long)FramingLength + headerLength + bodyLength > int.MaxValue) + { + throw new InvalidMessageFrameException($"Invalid message size: header ({headerLength}) and body ({bodyLength}) exceed the supported frame size."); + } } private void ThrowInvalidHeaderLength(int headerLength) => throw new InvalidMessageFrameException($"Invalid header size: {headerLength} (max configured value is {_maxHeaderLength}, see {nameof(MessagingOptions.MaxMessageHeaderSize)})"); private void ThrowInvalidBodyLength(int bodyLength) => throw new InvalidMessageFrameException($"Invalid body size: {bodyLength} (max configured value is {_maxBodyLength}, see {nameof(MessagingOptions.MaxMessageBodySize)})"); - private void Serialize(ref Writer writer, Message value, PackedHeaders headers) where TBufferWriter : IBufferWriter + private void SerializeHeaders(ref Writer writer, Message value, PackedHeaders headers) where TBufferWriter : IBufferWriter { writer.WriteUInt32((uint)headers); @@ -263,7 +206,7 @@ private void Serialize(ref Writer writer, Message if (headers.HasFlag(MessageFlags.HasCacheInvalidationHeader)) { - WriteCacheInvalidationHeaders(ref writer, value); + WriteCacheInvalidationHeaders(ref writer, value.CacheInvalidationHeader!); } // Always write RequestContext last @@ -273,7 +216,7 @@ private void Serialize(ref Writer writer, Message } } - private void Deserialize(ref Reader reader, Message result) + private void DeserializeHeaders(ref Reader reader, Message result) { var headers = (PackedHeaders)reader.ReadUInt32(); @@ -317,46 +260,32 @@ private void Deserialize(ref Reader reader, Message result) internal List ReadCacheInvalidationHeaders(ref Reader reader) { - var n = reader.ReadVarUInt32(); - if (n > 0) + var count = reader.ReadVarUInt32(); + if (count > 0) { - var count = Math.Min(n, (uint)Message.MaxCacheInvalidationHeaderEntries); - var list = new List((int)count); - for (uint i = 0; i < n; i++) + var retainedCount = (int)Math.Min(count, Message.MaxCacheInvalidationHeaderEntries); + var list = new List(retainedCount); + for (uint i = 0; i < count; i++) { - // Cache invalidation headers only contain non-null update records. - var update = _grainAddressCacheUpdateCodec.ReadValue(ref reader, reader.ReadFieldHeader())!; - if (i < count) + var entry = _activationAddressCodec.ReadValue(ref reader, reader.ReadFieldHeader())!; + if (list.Count < Message.MaxCacheInvalidationHeaderEntries) { - list.Add(update); + list.Add(entry); } } return list; } - return []; + return new List(); } - internal void WriteCacheInvalidationHeaders(ref Writer writer, Message message) where TBufferWriter : IBufferWriter + internal void WriteCacheInvalidationHeaders(ref Writer writer, List value) where TBufferWriter : IBufferWriter { - // Lock during enumeration to avoid concurrent modifications. - // The list can be modified by other threads after the message is queued for sending. - var cacheUpdates = message.CacheInvalidationHeader; - if (cacheUpdates is null) - { - writer.WriteVarUInt32(0u); - } - else + writer.WriteVarUInt32((uint)value.Count); + foreach (var entry in value) { - lock (cacheUpdates) - { - writer.WriteVarUInt32((uint)cacheUpdates.Count); - foreach (var entry in cacheUpdates) - { - _grainAddressCacheUpdateCodec.WriteField(ref writer, 0, typeof(GrainAddressCacheUpdate), entry); - } - } + _activationAddressCodec.WriteField(ref writer, 0, typeof(GrainAddressCacheUpdate), entry); } } @@ -408,7 +337,6 @@ private static Dictionary ReadRequestContext(ref Reader< for (var i = 0; i < size; i++) { var key = ReadString(ref reader); - // Request context dictionaries do not contain null values. var value = ObjectCodec.ReadValue(ref reader, reader.ReadFieldHeader())!; Debug.Assert(key is not null); @@ -433,13 +361,4 @@ private void WriteGrainId(ref Writer writer, Grain IdSpanCodec.WriteRaw(ref writer, value.Key); } } - - internal readonly struct MessageBufferWriter : IBufferWriter - { - private readonly PrefixingBufferWriter _buffer; - public MessageBufferWriter(PrefixingBufferWriter buffer) => _buffer = buffer; - public void Advance(int count) => _buffer.Advance(count); - public Memory GetMemory(int sizeHint = 0) => _buffer.GetMemory(sizeHint); - public Span GetSpan(int sizeHint = 0) => _buffer.GetSpan(sizeHint); - } } diff --git a/src/Orleans.Core/Messaging/PrefixingBufferWriter.cs b/src/Orleans.Core/Messaging/PrefixingBufferWriter.cs deleted file mode 100644 index e19a042a3b0..00000000000 --- a/src/Orleans.Core/Messaging/PrefixingBufferWriter.cs +++ /dev/null @@ -1,415 +0,0 @@ -using System; -using System.Buffers; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.IO.Pipelines; -using System.Runtime.CompilerServices; - -namespace Orleans.Runtime.Messaging -{ - /// - /// An that reserves some fixed size for a header. - /// - /// - /// This type is used for inserting the length of list in the header when the length is not known beforehand. - /// It is optimized to minimize or avoid copying. - /// - internal sealed class PrefixingBufferWriter : IBufferWriter, IDisposable - { - private readonly MemoryPool memoryPool; - - /// - /// The length of the header. - /// - private readonly int expectedPrefixSize; - - /// - /// A hint from our owner at the size of the payload that follows the header. - /// - private readonly int payloadSizeHint; - - /// - /// The underlying buffer writer. - /// - private PipeWriter? innerWriter; - - /// - /// The memory reserved for the header from the . - /// This memory is not reserved until the first call from this writer to acquire memory. - /// - private Memory prefixMemory; - - /// - /// The memory acquired from . - /// This memory is not reserved until the first call from this writer to acquire memory. - /// - private Memory realMemory; - - /// - /// The number of elements written to a buffer belonging to . - /// - private int advanced; - - /// - /// The fallback writer to use when the caller writes more than we allowed for given the - /// in anything but the initial call to . - /// - private Sequence? privateWriter; - - private int _committedBytes; - - /// - /// Initializes a new instance of the class. - /// - /// The length of the header to reserve space for. Must be a positive number. - /// A hint at the expected max size of the payload. The real size may be more or less than this, but additional copying is avoided if it does not exceed this amount. If 0, a reasonable guess is made. - /// - public PrefixingBufferWriter(int prefixSize, int payloadSizeHint, MemoryPool memoryPool) - { - if (prefixSize <= 0) - { - ThrowPrefixSize(); - } - - this.expectedPrefixSize = prefixSize; - this.payloadSizeHint = payloadSizeHint; - this.memoryPool = memoryPool; - static void ThrowPrefixSize() => throw new ArgumentOutOfRangeException(nameof(prefixSize)); - } - - public int CommittedBytes => _committedBytes; - - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Advance(int count) - { - if (privateWriter == null) - { - advanced += count; - _committedBytes += count; - } - else - { - AdvancePrivateWriter(count); - } - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private void AdvancePrivateWriter(int count) - { - privateWriter!.Advance(count); - _committedBytes += count; - } - - /// - public Memory GetMemory(int sizeHint = 0) - { - if (privateWriter == null) - { - if (prefixMemory.IsEmpty) - Initialize(sizeHint); - - var res = realMemory[advanced..]; - if (!res.IsEmpty && (uint)sizeHint <= (uint)res.Length) - return res; - - privateWriter = new(memoryPool); - } - - return privateWriter.GetMemory(sizeHint); - } - - /// - public Span GetSpan(int sizeHint = 0) - { - if (privateWriter == null) - { - var res = realMemory.Span[advanced..]; - if (!res.IsEmpty && (uint)sizeHint <= (uint)res.Length) - return res; - } - - return GetMemory(sizeHint).Span; - } - - /// - /// Inserts the prefix and commits the payload to the underlying . - /// - /// The prefix to write in. The length must match the one given in the constructor. - public void Complete(ReadOnlySpan prefix) - { - if (prefix.Length != this.expectedPrefixSize) - { - ThrowPrefixLength(); - static void ThrowPrefixLength() => throw new ArgumentOutOfRangeException(nameof(prefix), "Prefix was not expected length."); - } - - if (this.prefixMemory.Length == 0) - { - // No payload was actually written, and we never requested memory, so just write it out. - this.innerWriter!.Write(prefix); - } - else - { - // Payload has been written, so write in the prefix then commit the payload. - prefix.CopyTo(this.prefixMemory.Span); - this.innerWriter!.Advance(prefix.Length + this.advanced); - if (this.privateWriter != null) - CompletePrivateWriter(); - } - } - - private void CompletePrivateWriter() - { - var sequence = privateWriter!.AsReadOnlySequence; - var sequenceLength = checked((int)sequence.Length); - sequence.CopyTo(innerWriter!.GetSpan(sequenceLength)); - innerWriter!.Advance(sequenceLength); - } - - /// - /// Sets this instance to a usable state. - /// - /// The underlying writer that should ultimately receive the prefix and payload. - public void Init(PipeWriter writer) => innerWriter = writer; - - /// - /// Resets this instance to a reusable state. - /// - public void Reset() - { - privateWriter?.Dispose(); - privateWriter = null; - prefixMemory = default; - realMemory = default; - innerWriter = null; - advanced = 0; - _committedBytes = 0; - } - - public void Dispose() - { - this.privateWriter?.Dispose(); - } - - /// - /// Makes the initial call to acquire memory from the underlying writer. - /// - /// The size requested by the caller to either or . - private void Initialize(int sizeHint) - { - int sizeToRequest = this.expectedPrefixSize + Math.Max(sizeHint, this.payloadSizeHint); - var memory = this.innerWriter!.GetMemory(sizeToRequest); - this.prefixMemory = memory[..this.expectedPrefixSize]; - this.realMemory = memory[this.expectedPrefixSize..]; - } - - /// - /// Manages a sequence of elements, readily castable as a . - /// - /// - /// Instance members are not thread-safe. - /// - [DebuggerDisplay("{" + nameof(DebuggerDisplay) + ",nq}")] - private sealed class Sequence - { - private const int DefaultBufferSize = 4 * 1024; - - private readonly Stack segmentPool = new Stack(); - - private readonly MemoryPool memoryPool; - - private SequenceSegment? first; - - private SequenceSegment? last; - - /// - /// Initializes a new instance of the class. - /// - /// The pool to use for recycling backing arrays. - public Sequence(MemoryPool memoryPool) - { - if (memoryPool is null) ThrowNull(); - this.memoryPool = memoryPool; - - [DoesNotReturn] - static void ThrowNull() => throw new ArgumentNullException(nameof(memoryPool)); - } - - /// - /// Gets this sequence expressed as a . - /// - /// A read only sequence representing the data in this object. - public ReadOnlySequence AsReadOnlySequence => first != null ? new(first, first.Start, last!, last!.End) : default; - - /// - /// Gets the value to display in a debugger datatip. - /// - private string DebuggerDisplay => $"Length: {AsReadOnlySequence.Length}"; - - /// - /// Advances the sequence to include the specified number of elements initialized into memory - /// returned by a prior call to . - /// - /// The number of elements written into memory. - public void Advance(int count) => last!.Advance(count); - - /// - /// Gets writable memory that can be initialized and added to the sequence via a subsequent call to . - /// - /// The size of the memory required, or 0 to just get a convenient (non-empty) buffer. - /// The requested memory. - public Memory GetMemory(int sizeHint) - => last?.TrailingSlack is { Length: > 0 } slack && (uint)slack.Length >= (uint)sizeHint ? slack : Append(sizeHint); - - /// - /// Clears the entire sequence, recycles associated memory into pools, - /// and resets this instance for reuse. - /// This invalidates any previously produced by this instance. - /// - public void Dispose() - { - SequenceSegment? current = this.first; - while (current != null) - { - current = this.RecycleAndGetNext(current); - } - - this.first = this.last = null; - } - - private Memory Append(int sizeHint) - { - var array = memoryPool.Rent(Math.Min(sizeHint > 0 ? sizeHint : DefaultBufferSize, memoryPool.MaxBufferSize)); - - var segment = this.segmentPool.Count > 0 ? this.segmentPool.Pop() : new SequenceSegment(); - segment.SetMemory(array); - - if (this.last == null) - { - this.first = this.last = segment; - } - else - { - if (this.last.Length > 0) - { - // Add a new block. - this.last.SetNext(segment); - } - else - { - // The last block is completely unused. Replace it instead of appending to it. - var current = this.first!; - if (this.first != this.last) - { - while (current.Next != this.last) - { - current = current.Next!; - } - } - else - { - this.first = segment; - } - - current.SetNext(segment); - this.RecycleAndGetNext(this.last); - } - - this.last = segment; - } - - return segment.AvailableMemory; - } - - private SequenceSegment? RecycleAndGetNext(SequenceSegment segment) - { - var recycledSegment = segment; - segment = segment.Next!; - recycledSegment.ResetMemory(); - this.segmentPool.Push(recycledSegment); - return segment; - } - - private sealed class SequenceSegment : ReadOnlySequenceSegment - { - /// - /// Gets the index of the first element in to consider part of the sequence. - /// - /// - /// The represents the offset into where the range of "active" bytes begins. At the point when the block is leased - /// the is guaranteed to be equal to 0. The value of may be assigned anywhere between 0 and - /// .Length, and must be equal to or less than . - /// - internal int Start { get; private set; } - - /// - /// Gets or sets the index of the element just beyond the end in to consider part of the sequence. - /// - /// - /// The represents the offset into where the range of "active" bytes ends. At the point when the block is leased - /// the is guaranteed to be equal to . The value of may be assigned anywhere between 0 and - /// .Length, and must be equal to or less than . - /// - internal int End { get; private set; } - - internal Memory TrailingSlack => this.AvailableMemory[this.End..]; - - private IMemoryOwner? MemoryOwner; - - internal Memory AvailableMemory; - - internal int Length => this.End - this.Start; - - internal new SequenceSegment? Next - { - get => (SequenceSegment?)base.Next; - set => base.Next = value; - } - - internal void SetMemory(IMemoryOwner memoryOwner) - { - this.MemoryOwner = memoryOwner; - this.AvailableMemory = memoryOwner.Memory; - } - - internal void ResetMemory() - { - this.MemoryOwner!.Dispose(); - this.MemoryOwner = null; - this.AvailableMemory = default; - - this.Memory = default; - this.Next = null; - this.RunningIndex = 0; - this.Start = 0; - this.End = 0; - } - - internal void SetNext(SequenceSegment segment) - { - segment.RunningIndex = this.RunningIndex + this.End; - this.Next = segment; - } - - public void Advance(int count) - { - if (count < 0) ThrowNegative(); - var value = End + count; - - // If we ever support creating these instances on existing arrays, such that - // this.Start isn't 0 at the beginning, we'll have to "pin" this.Start and remove - // Advance, forcing Sequence itself to track it, the way Pipe does it internally. - this.Memory = AvailableMemory[..value]; - this.End = value; - - static void ThrowNegative() => throw new ArgumentOutOfRangeException( - nameof(count), - "Value must be greater than or equal to 0"); - } - - } - } - } -} diff --git a/src/Orleans.Core/Networking/ClientConnectionOptions.cs b/src/Orleans.Core/Networking/ClientConnectionOptions.cs deleted file mode 100644 index a57c42a7ac0..00000000000 --- a/src/Orleans.Core/Networking/ClientConnectionOptions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using Microsoft.AspNetCore.Connections; - -namespace Orleans.Configuration -{ - /// - /// Options for clients connections. - /// - public class ClientConnectionOptions - { - private readonly ConnectionBuilderDelegates delegates = new ConnectionBuilderDelegates(); - - /// - /// Adds a connection configuration delegate. - /// - /// The configuration delegate. - public void ConfigureConnection(Action configure) => this.delegates.Add(configure); - - /// - /// Configures the provided connection builder using these options. - /// - /// The connection builder. - internal void ConfigureConnectionBuilder(IConnectionBuilder builder) => this.delegates.Invoke(builder); - } -} diff --git a/src/Orleans.Core/Networking/ClientOutboundConnection.cs b/src/Orleans.Core/Networking/ClientOutboundConnection.cs index ce39003e226..a8f932af810 100644 --- a/src/Orleans.Core/Networking/ClientOutboundConnection.cs +++ b/src/Orleans.Core/Networking/ClientOutboundConnection.cs @@ -2,10 +2,10 @@ using System.Diagnostics; using System.Text; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; using Microsoft.Extensions.Logging; using Orleans.Configuration; using Orleans.Messaging; +using Orleans.Connections.Transport; namespace Orleans.Runtime.Messaging { @@ -19,15 +19,14 @@ internal sealed partial class ClientOutboundConnection : Connection public ClientOutboundConnection( SiloAddress remoteSiloAddress, - ConnectionContext connection, - ConnectionDelegate middleware, + MessageTransport transport, ClientMessageCenter messageCenter, ConnectionManager connectionManager, - ConnectionOptions connectionOptions, ConnectionCommon connectionShared, + ConnectionOptions connectionOptions, ConnectionPreambleHelper connectionPreambleHelper, ClusterOptions clusterOptions) - : base(connection, middleware, connectionShared) + : base(transport, connectionShared) { this.messageCenter = messageCenter; this.connectionManager = connectionManager; @@ -41,24 +40,23 @@ public ClientOutboundConnection( protected override ConnectionDirection ConnectionDirection => ConnectionDirection.ClientToGateway; - protected override IMessageCenter MessageCenter => this.messageCenter; + protected override TimeSpan CloseConnectionTimeout => this.connectionOptions.CloseConnectionTimeout; - protected override void RecordMessageReceive(Message msg, int numTotalBytes, int headerBytes) - { - MessagingInstrumentation.OnMessageReceive(msg, numTotalBytes, headerBytes, ConnectionDirection, RemoteSiloAddress); - } + protected override ClientMessageCenter MessageCenter => this.messageCenter; - protected override void RecordMessageSend(Message msg, int numTotalBytes, int headerBytes) - { - MessagingInstrumentation.OnMessageSend(msg, numTotalBytes, headerBytes, ConnectionDirection, RemoteSiloAddress); - } + internal protected override void RecordMessageReceive(Message message, int totalBytes, int headerBytes) => + MessagingMetrics.OnMessageReceive(message, totalBytes, headerBytes, ConnectionDirection, RemoteSiloAddress); + + internal protected override void RecordMessageSend(Message message, int totalBytes, int headerBytes) => + MessagingMetrics.OnMessageSend(message, totalBytes, headerBytes, ConnectionDirection, RemoteSiloAddress); - protected override void OnReceivedMessage(Message message) + internal protected override void OnReceivedMessage(Message message) { + message.SendingSilo ??= RemoteSiloAddress; this.messageCenter.DispatchLocalMessage(message); } - protected override async Task RunInternal() + protected override async Task RunAsyncCore() { Exception? error = default; try @@ -88,7 +86,7 @@ await connectionPreambleHelper.Write( throw new InvalidOperationException($@"Unexpected cluster id ""{preamble.ClusterId}"", expected ""{myClusterId}"""); } - await base.RunInternal(); + await base.RunAsyncCore(); } catch (Exception exception) when ((error = exception) is null) { @@ -140,9 +138,13 @@ protected override void RetryMessage(Message msg, Exception? ex = null) } } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the rejection message is transferred to the local message dispatcher.")] internal void SendRejection(Message msg, Message.RejectionTypes rejectionType, string reason) { - MessagingInstrumentation.OnRejectedMessage(msg); + MessagingMetrics.OnRejectedMessage(msg); if (string.IsNullOrEmpty(reason)) reason = "Rejection from silo - Unknown reason."; var error = this.MessageFactory.CreateRejectionResponse(msg, rejectionType, reason); @@ -152,7 +154,7 @@ internal void SendRejection(Message msg, Message.RejectionTypes rejectionType, s public void FailMessage(Message msg, string reason) { - MessagingInstrumentation.OnFailedSentMessage(msg); + MessagingMetrics.OnFailedSentMessage(msg); if (msg.Direction == Message.Directions.Request) { LogDebugClientIsRejectingMessage(this.Log, msg, reason); @@ -162,14 +164,10 @@ public void FailMessage(Message msg, string reason) else { LogInformationClientIsDroppingMessage(this.Log, msg, reason); - MessagingInstrumentation.OnDroppedSentMessage(msg); + MessagingMetrics.OnDroppedSentMessage(msg); } - } - protected override void OnSendMessageFailure(Message message, string error) - { - message.TargetSilo = null; - this.messageCenter.SendMessage(message); + msg.Dispose(); } [LoggerMessage( diff --git a/src/Orleans.Core/Networking/ClientOutboundConnectionFactory.cs b/src/Orleans.Core/Networking/ClientOutboundConnectionFactory.cs index 69ae3f03e1c..e6a7afb4278 100644 --- a/src/Orleans.Core/Networking/ClientOutboundConnectionFactory.cs +++ b/src/Orleans.Core/Networking/ClientOutboundConnectionFactory.cs @@ -1,78 +1,67 @@ -using System.Threading; -using Microsoft.AspNetCore.Connections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Net; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Orleans.Configuration; +using Orleans.Connections.Transport; using Orleans.Messaging; namespace Orleans.Runtime.Messaging { - internal sealed class ClientOutboundConnectionFactory : ConnectionFactory + internal sealed class ClientOutboundConnectionFactory( + IOptions connectionOptions, + IOptions clusterOptions, + MessageTransportConnector connector, + IEnumerable connectorMiddleware, + ConnectionCommon connectionShared, + ConnectionPreambleHelper connectionPreambleHelper) : ConnectionFactory(connector, connectorMiddleware) { - internal static readonly object ServicesKey = new object(); - private readonly ConnectionCommon connectionShared; - private readonly ClientConnectionOptions clientConnectionOptions; - private readonly ClusterOptions clusterOptions; - private readonly ConnectionPreambleHelper connectionPreambleHelper; -#if NET9_0_OR_GREATER - private readonly Lock initializationLock = new(); -#else - private readonly object initializationLock = new(); -#endif - private volatile bool isInitialized; - private ClientMessageCenter messageCenter = null!; - private ConnectionManager connectionManager = null!; + private readonly object _initializationLock = new(); + private readonly ConnectionCommon _connectionShared = connectionShared; + private readonly ConnectionOptions _connectionOptions = connectionOptions.Value; + private readonly ClusterOptions _clusterOptions = clusterOptions.Value; + private readonly ConnectionPreambleHelper _connectionPreambleHelper = connectionPreambleHelper; + private volatile bool _isInitialized; + private ClientMessageCenter? _messageCenter; + private ConnectionManager? _connectionManager; - public ClientOutboundConnectionFactory( - IOptions connectionOptions, - IOptions clientConnectionOptions, - IOptions clusterOptions, - ConnectionCommon connectionShared, - ConnectionPreambleHelper connectionPreambleHelper) - : base(connectionShared.ServiceProvider.GetRequiredKeyedService(ServicesKey), connectionShared.ServiceProvider, connectionOptions) - { - this.connectionShared = connectionShared; - this.clientConnectionOptions = clientConnectionOptions.Value; - this.clusterOptions = clusterOptions.Value; - this.connectionPreambleHelper = connectionPreambleHelper; - } - - protected override Connection CreateConnection(SiloAddress address, ConnectionContext context) + protected override Connection CreateConnection(SiloAddress address, MessageTransport transport) { EnsureInitialized(); return new ClientOutboundConnection( address, - context, - this.ConnectionDelegate, - this.messageCenter, - this.connectionManager, - this.ConnectionOptions, - this.connectionShared, - this.connectionPreambleHelper, - this.clusterOptions); + transport, + _messageCenter, + _connectionManager, + _connectionShared, + _connectionOptions, + _connectionPreambleHelper, + _clusterOptions); } - protected override void ConfigureConnectionBuilder(IConnectionBuilder connectionBuilder) - { - this.clientConnectionOptions.ConfigureConnectionBuilder(connectionBuilder); - base.ConfigureConnectionBuilder(connectionBuilder); - } + protected override EndPoint GetEndPoint(SiloAddress address) => address.Endpoint; + [MemberNotNull(nameof(_messageCenter), nameof(_connectionManager))] private void EnsureInitialized() { - if (!isInitialized) + if (!_isInitialized) { - lock (this.initializationLock) + lock (_initializationLock) { - if (!isInitialized) + if (!_isInitialized) { - this.messageCenter = this.connectionShared.ServiceProvider.GetRequiredService(); - this.connectionManager = this.connectionShared.ServiceProvider.GetRequiredService(); - this.isInitialized = true; + _messageCenter = _connectionShared.ServiceProvider.GetRequiredService(); + _connectionManager = _connectionShared.ServiceProvider.GetRequiredService(); + _isInitialized = true; } } } + + Debug.Assert(_messageCenter is not null); + Debug.Assert(_connectionManager is not null); } } } diff --git a/src/Orleans.Core/Networking/Connection.cs b/src/Orleans.Core/Networking/Connection.cs index 851658c7e0f..7cb4e6ffaed 100644 --- a/src/Orleans.Core/Networking/Connection.cs +++ b/src/Orleans.Core/Networking/Connection.cs @@ -1,118 +1,96 @@ using System; -using System.Collections.Generic; +using System.Collections.Concurrent; using System.Diagnostics; -using System.IO.Pipelines; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Threading; -using System.Threading.Channels; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ObjectPool; using Orleans.Configuration; +using Orleans.Connections; +using Orleans.Connections.Transport; using Orleans.Messaging; +using Orleans.Runtime.Internal; using Orleans.Serialization.Invocation; namespace Orleans.Runtime.Messaging { internal abstract partial class Connection { - private static readonly Func OnConnectedDelegate = context => OnConnectedAsync(context); - private static readonly Action OnConnectionClosedDelegate = state => ((Connection)state!).OnTransportConnectionClosed(); - private static readonly UnboundedChannelOptions OutgoingMessageChannelOptions = new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false, - AllowSynchronousContinuations = false - }; - - private static readonly ObjectPool MessageHandlerPool = ObjectPool.Create(new MessageHandlerPoolPolicy()); - private readonly ConnectionCommon shared; - private readonly ConnectionDelegate middleware; - private readonly Channel outgoingMessages; - private readonly ChannelWriter outgoingMessageWriter; - private readonly List inflight = new List(4); - private readonly TaskCompletionSource _transportConnectionClosed = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly TaskCompletionSource _initializationTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly TaskCompletionSource _middlewareCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly IDuplexPipe _underlyingTransport; - private IDuplexPipe? _transport; + private readonly ConnectionCommon _shared; + private readonly TaskCompletionSource _initializationTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _startedClosing = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _runCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly string _id; + private readonly MessageTransport _transport; + private readonly SendWorker _sendWorker; private Task? _processIncomingTask; - private Task? _processOutgoingTask; private Task? _closeTask; private int _runState; private bool _openedSocket; private long _lastMessageReceivedTimestamp; protected Connection( - ConnectionContext connection, - ConnectionDelegate middleware, + MessageTransport transport, ConnectionCommon shared) { - this.Context = connection ?? throw new ArgumentNullException(nameof(connection)); - _underlyingTransport = connection.Transport; - this.middleware = middleware ?? throw new ArgumentNullException(nameof(middleware)); - this.shared = shared; - this.outgoingMessages = Channel.CreateUnbounded(OutgoingMessageChannelOptions); - this.outgoingMessageWriter = this.outgoingMessages.Writer; - - // Set the connection on the connection context so that it can be retrieved by the middleware. - this.Context.Features.Set(this); - - this.RemoteEndPoint = NormalizeEndpoint(this.Context.RemoteEndPoint)!; - this.LocalEndPoint = NormalizeEndpoint(this.Context.LocalEndPoint)!; - this.Context.ConnectionClosed.UnsafeRegister(OnConnectionClosedDelegate, this); + _id = CorrelationIdGenerator.GetNextId(); + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + _shared = shared; + + _sendWorker = new(this); + + _transport.Closed.Register(static state => ((Connection)state!).OnTransportConnectionClosed(), this); } - public ConnectionCommon Shared => shared; - public string ConnectionId => (this.Context?.ConnectionId)!; - public virtual EndPoint RemoteEndPoint { get; } - public virtual EndPoint LocalEndPoint { get; } - protected ConnectionContext Context { get; } - protected ILogger Log => this.shared.Logger; - protected MessagingTrace MessagingTrace => this.shared.MessagingTrace; - protected MessagingInstruments MessagingInstrumentation => this.shared.MessagingInstruments; - protected abstract ConnectionDirection ConnectionDirection { get; } - protected MessageFactory MessageFactory => this.shared.MessageFactory; - protected abstract IMessageCenter MessageCenter { get; } + public string ConnectionId => _id; - public bool IsValid => _closeTask is null; + public EndPoint RemoteEndPoint => _transport.Features.Get()?.RemoteEndPoint ?? UnknownEndPoint.Instance; - public Task Initialized => _initializationTcs.Task; + public EndPoint LocalEndPoint => _transport.Features.Get()?.LocalEndPoint ?? UnknownEndPoint.Instance; + + protected MessageTransport Context => _transport; + protected ConnectionTrace Log => _shared.ConnectionTrace; + protected MessagingTrace MessagingTrace => _shared.MessagingTrace; + protected MessagingInstruments MessagingMetrics => _shared.MessagingInstruments; + protected NetworkingInstruments NetworkingMetrics => _shared.NetworkingInstruments; + protected abstract ConnectionDirection ConnectionDirection { get; } + protected MessageFactory MessageFactory => _shared.MessageFactory; + protected abstract IMessageCenter MessageCenter { get; } /// - /// Gets the time elapsed since the last message was received on this connection, - /// or if no message has been received yet. + /// Gets the timeout for gracefully closing the connection. /// + protected abstract TimeSpan CloseConnectionTimeout { get; } + + public bool IsValid => _closeTask is null; + public Task Initialized => _initializationTcs.Task; public TimeSpan? ElapsedSinceLastMessageReceived { get { var timestamp = Volatile.Read(ref _lastMessageReceivedTimestamp); - if (timestamp == 0) return null; - return TimeSpan.FromMilliseconds(CoarseStopwatch.GetTimestamp() - timestamp); + return timestamp == 0 ? null : TimeSpan.FromMilliseconds(CoarseStopwatch.GetTimestamp() - timestamp); } } - protected void MarkMessageReceived() => Volatile.Write(ref _lastMessageReceivedTimestamp, CoarseStopwatch.GetTimestamp()); - - public static void ConfigureBuilder(ConnectionBuilder builder) => builder.Run(OnConnectedDelegate); + internal void MarkMessageReceived() => Volatile.Write(ref _lastMessageReceivedTimestamp, CoarseStopwatch.GetTimestamp()); /// /// Start processing this connection. /// /// A which completes when the connection terminates and has completed processing. - public async Task Run() + public async Task RunAsync() { - var runMiddleware = Interlocked.CompareExchange(ref _runState, 1, 0) == 0; + var run = Interlocked.CompareExchange(ref _runState, 1, 0) == 0; Exception? error = default; try { - if (runMiddleware) + if (run) { - // Eventually calls through to OnConnectedAsync (unless the connection delegate has been misconfigured) - await this.middleware(this.Context); + NetworkingMetrics.OnOpenedSocket(ConnectionDirection); + _openedSocket = true; + await RunAsyncCore(); } } catch (Exception exception) @@ -121,30 +99,24 @@ public async Task Run() } finally { - if (runMiddleware) + if (run) { - _middlewareCompleted.TrySetResult(); + _runCompleted.TrySetResult(); } - await this.CloseAsync(error); + await CloseAsync(error); } } - private static Task OnConnectedAsync(ConnectionContext context) + protected virtual Task RunAsyncCore() { - var connection = context.Features.Get(); - connection!.shared.NetworkingInstruments.OnOpenedSocket(connection.ConnectionDirection); - connection._openedSocket = true; - return connection.RunInternal(); - } + using (new ExecutionContextSuppressor()) + { + _processIncomingTask = ProcessIncoming(); + } - protected virtual async Task RunInternal() - { - _transport = this.Context.Transport; - _processIncomingTask = this.ProcessIncoming(); - _processOutgoingTask = this.ProcessOutgoing(); - _initializationTcs.TrySetResult(0); - await Task.WhenAll(_processIncomingTask, _processOutgoingTask); + _initializationTcs.TrySetResult(); + return _processIncomingTask; } /// @@ -159,15 +131,15 @@ protected virtual async Task RunInternal() public Task CloseAsync(Exception? exception) { StartClosing(exception); - return _closeTask!; + return _closeTask; } private void OnTransportConnectionClosed() { - StartClosing(new ConnectionAbortedException("Underlying connection closed")); - _transportConnectionClosed.SetResult(0); + StartClosing(new ConnectionClosedException("Underlying connection closed.")); } + [MemberNotNull(nameof(_closeTask))] private void StartClosing(Exception? exception) { if (_closeTask is not null) @@ -175,23 +147,26 @@ private void StartClosing(Exception? exception) return; } + using var _ = new ExecutionContextSuppressor(); var task = new Task(CloseAsync); if (Interlocked.CompareExchange(ref _closeTask, task.Unwrap(), null) is not null) { return; } - // Either Run owns middleware completion, or closing permanently prevents middleware from starting. if (Interlocked.CompareExchange(ref _runState, 2, 0) == 0) { - _middlewareCompleted.TrySetResult(); + _runCompleted.TrySetResult(); } - _initializationTcs.TrySetException(exception ?? new ConnectionAbortedException("Connection initialization failed")); - _initializationTcs.Task.Ignore(); + if (!_initializationTcs.Task.IsCompleted) + { + _initializationTcs.TrySetException(exception ?? new ConnectionAbortedException("Connection initialization failed.")); + } - LogInformationClosingConnection(this.Log, exception, this); + _initializationTcs.Task.Ignore(); + LogInformationClosingConnection(Log, exception is not ConnectionClosedException ? exception : null, this); task.Start(TaskScheduler.Default); } @@ -200,13 +175,20 @@ private void StartClosing(Exception? exception) /// private async Task CloseAsync() { - // Signal the outgoing message processor to exit gracefully. - this.outgoingMessageWriter.TryComplete(); + var sendWorkerTask = _sendWorker.StopAsync(); + + try + { + using var timeoutCts = new CancellationTokenSource(CloseConnectionTimeout); + await _transport.CloseAsync(new ConnectionClosedException(), timeoutCts.Token); + } + catch (Exception closeException) + { + LogWarningExceptionTerminatingConnection(Log, closeException, this); + } - _underlyingTransport.Input.CancelPendingRead(); - _underlyingTransport.Output.CancelPendingFlush(); + await _runCompleted.Task.ConfigureAwait(false); - // Try to gracefully stop the reader/writer loops, if they are running. if (_processIncomingTask is { IsCompleted: false } incoming) { try @@ -215,390 +197,288 @@ private async Task CloseAsync() } catch (Exception processIncomingException) { - // Swallow any exceptions here. - LogWarningExceptionProcessingIncomingMessages(this.Log, processIncomingException, this); + LogWarningExceptionProcessingIncomingMessages(Log, processIncomingException, this); } } - if (_processOutgoingTask is { IsCompleted: false } outgoing) - { - try - { - await outgoing; - } - catch (Exception processOutgoingException) - { - // Swallow any exceptions here. - LogWarningExceptionProcessingOutgoingMessages(this.Log, processOutgoingException, this); - } - } - - // Abort also releases transport work while TLS or the Orleans preamble is still initializing. - try - { - this.Context.Abort(); - } - catch (Exception exception) - { - LogWarningExceptionAbortingConnection(this.Log, exception, this); - } - - await _transportConnectionClosed.Task; - await _middlewareCompleted.Task; + await sendWorkerTask.ConfigureAwait(false); if (_openedSocket) { - this.shared.NetworkingInstruments.OnClosedSocket(this.ConnectionDirection); + NetworkingMetrics.OnClosedSocket(ConnectionDirection); } try { - await this.Context.DisposeAsync(); + await _transport.DisposeAsync(); } catch (Exception abortException) { - // Swallow any exceptions here. - LogWarningExceptionTerminatingConnection(this.Log, abortException, this); - } - - // Reject in-flight messages. - foreach (var message in this.inflight) - { - this.OnSendMessageFailure(message, "Connection terminated"); + LogWarningExceptionTerminatingConnection(Log, abortException, this); } - this.inflight.Clear(); - - // Reroute enqueued messages. - var i = 0; - while (this.outgoingMessages.Reader.TryRead(out var message)) - { - if (i == 0) - { - LogInformationReroutingMessages(this.Log, new EndPointLogValue(this.RemoteEndPoint)); - } - - ++i; - this.RetryMessage(message); - } - - if (i > 0) - { - LogInformationReroutedMessages(this.Log, i, new EndPointLogValue(this.RemoteEndPoint)); - } } public virtual void Send(Message message) { Debug.Assert(!message.IsLocalOnly); - if (!this.outgoingMessageWriter.TryWrite(message)) - { - this.RerouteMessage(message); - } + _sendWorker.Schedule(message); } - public override string ToString() => $"[Local: {this.LocalEndPoint}, Remote: {this.RemoteEndPoint}, ConnectionId: {this.Context.ConnectionId}]"; + private sealed class UnknownEndPoint : EndPoint + { + public static UnknownEndPoint Instance { get; } = new(); - protected abstract void RecordMessageReceive(Message msg, int numTotalBytes, int headerBytes); - protected abstract void RecordMessageSend(Message msg, int numTotalBytes, int headerBytes); - protected abstract void OnReceivedMessage(Message message); - protected abstract void OnSendMessageFailure(Message message, string error); + public override string ToString() => "unknown"; + } - private async Task ProcessIncoming() + private sealed class SendWorker(Connection connection) : IThreadPoolWorkItem { - await Task.Yield(); - - Exception? error = default; - using var serializerScope = this.shared.ServiceProvider.CreateScope(); - var serializer = serializerScope.ServiceProvider.GetRequiredService(); - var prevBufferLength = 0L; - try - { - var input = this._transport!.Input; - var requiredBytes = 0; - while (true) + private const int MaxMessagesPerBatch = 64; + private const int SoftMaxBatchBytes = 64 * 1024; + private readonly ConcurrentQueue _workItems = new(); + private readonly Action? _messageObserver = connection._shared.MessageObserver; + private readonly Connection _connection = connection; + private readonly TaskCompletionSource _stopped = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _active; + private int _scheduling; + private int _stopping; + + public void Schedule(Message message) + { + if (!_connection._shared.MessageHandlerShared.TryAcquireSendWork()) { - var readResult = await input.ReadAsync(); + _connection.RerouteMessage(message, new ConnectionClosedException()); + return; + } - var buffer = readResult.Buffer; - if (buffer.Length > prevBufferLength) + var queued = false; + Interlocked.Increment(ref _scheduling); + try + { + if (Volatile.Read(ref _stopping) != 0) { - prevBufferLength = buffer.Length; - MarkMessageReceived(); + _connection.RerouteMessage(message, new ConnectionClosedException()); + return; } - if (buffer.Length >= requiredBytes) + _workItems.Enqueue(message); + queued = true; + Activate(); + } + finally + { + if (!queued) { - prevBufferLength = 0; - do - { - Message? message = default; - try - { - int headerLength, bodyLength; - (requiredBytes, headerLength, bodyLength) = serializer.TryRead(ref buffer, out message); - if (requiredBytes == 0) - { - Debug.Assert(message is not null); - RecordMessageReceive(message, bodyLength + headerLength, headerLength); - var handler = MessageHandlerPool.Get(); - handler.Set(message, this); - ThreadPool.UnsafeQueueUserWorkItem(handler, preferLocal: true); - } - } - catch (Exception exception) - { - if (!HandleReceiveMessageFailure(message, exception)) - { - throw; - } - } - } while (requiredBytes == 0); + _connection._shared.MessageHandlerShared.ReleaseSendWork(); } - if (readResult.IsCanceled || readResult.IsCompleted) + if (Interlocked.Decrement(ref _scheduling) == 0 && Volatile.Read(ref _stopping) != 0) { - break; + Activate(); } - - input.AdvanceTo(buffer.Start, buffer.End); } } - catch (Exception exception) - { - if (IsValid) - { - LogWarningExceptionProcessingMessagesFromRemote(this.Log, exception, this.RemoteEndPoint); - } - error = exception; - } - finally + public Task StopAsync() { - await _transport!.Input.CompleteAsync(); - this.StartClosing(error); + Volatile.Write(ref _stopping, 1); + Activate(); + return _stopped.Task; } - } - private async Task ProcessOutgoing() - { - await Task.Yield(); - - Exception? error = default; - using var serializerScope = this.shared.ServiceProvider.CreateScope(); - var serializer = serializerScope.ServiceProvider.GetRequiredService(); - var messageObserver = this.shared.MessageStatisticsSink.GetMessageObserver(); - try + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Each dequeued message is transferred to the write request or handled by the rejection/reroute path.")] + void IThreadPoolWorkItem.Execute() { - var output = this._transport!.Output; - var reader = this.outgoingMessages.Reader; - - while (true) + try { - var more = await reader.WaitToReadAsync(); - if (!more) + if (Volatile.Read(ref _stopping) != 0) { - break; + while (_workItems.TryDequeue(out var message)) + { + try + { + _connection.RerouteMessage(message, new ConnectionClosedException()); + } + finally + { + _connection._shared.MessageHandlerShared.ReleaseSendWork(); + } + } + + return; } - Message? message = default; - try + var writeRequest = _connection._shared.MessageHandlerShared.GetSendMessageHandler(_connection); + var attempts = 0; + while (attempts++ < MaxMessagesPerBatch + && writeRequest.Length < SoftMaxBatchBytes + && !writeRequest.HasLargeMessages + && _workItems.TryDequeue(out var message)) { - while (inflight.Count < inflight.Capacity && reader.TryRead(out message) && this.PrepareMessageForSend(message)) + try + { + if (Volatile.Read(ref _stopping) != 0) + { + _connection.RerouteMessage(message, new ConnectionClosedException()); + continue; + } + + if (!_connection.PrepareMessageForSend(message)) + { + continue; + } + + try + { + writeRequest.WriteMessage(message); + _messageObserver?.Invoke(message); + } + catch (Exception exception) + { + _connection.OnMessageSerializationFailure(message, exception); + break; + } + } + finally { - inflight.Add(message); - var (headerLength, bodyLength) = serializer.Write(output, message); - RecordMessageSend(message, headerLength + bodyLength, headerLength); - messageObserver?.Invoke(message); - message = null; + _connection._shared.MessageHandlerShared.ReleaseSendWork(); } } - catch (Exception exception) + + writeRequest.CompleteWriting(); + if (writeRequest.MessageCount == 0) + { + writeRequest.Reset(); + } + else if (!_connection._transport.EnqueueWrite(writeRequest)) { - if (!HandleSendMessageFailure(message, exception)) + _connection.StartClosing(new ConnectionClosedException()); + for (var i = 0; i < writeRequest.MessageCount; i++) { - throw; + _connection.RerouteMessage(writeRequest.GetMessage(i)); } - } - var flushResult = await output.FlushAsync(); - if (flushResult.IsCompleted || flushResult.IsCanceled) + writeRequest.Reset(); + } + } + finally + { + Volatile.Write(ref _active, 0); + if (!_workItems.IsEmpty || Volatile.Read(ref _scheduling) != 0) { - break; + Activate(); + } + else if (Volatile.Read(ref _stopping) != 0) + { + _stopped.TrySetResult(); } - - inflight.Clear(); } } - catch (Exception exception) + + private void Activate() { - if (IsValid) + if (Interlocked.CompareExchange(ref _active, 1, 0) == 0) { - LogWarningExceptionProcessingMessagesToRemote(this.Log, exception, this.RemoteEndPoint); + ThreadPool.UnsafeQueueUserWorkItem(this, preferLocal: true); } - - error = exception; - } - finally - { - await _transport!.Output.CompleteAsync(); - this.StartClosing(error); } } - private void RerouteMessage(Message message) - { - LogInformationReroutingMessage(this.Log, message, new EndPointLogValue(this.RemoteEndPoint)); + public override string ToString() => $"{nameof(Connection)}(Id: {_id}, Transport: {_transport})"; - ThreadPool.UnsafeQueueUserWorkItem(state => - { - var (t, msg) = ((Connection, Message))state!; - t.RetryMessage(msg); - }, (this, message)); + internal protected abstract void OnReceivedMessage(Message message); + internal protected abstract void RecordMessageReceive(Message message, int totalBytes, int headerBytes); + internal protected abstract void RecordMessageSend(Message message, int totalBytes, int headerBytes); + public void OnReadCompleted(Exception error) + { + StartClosing(error); + _startedClosing.TrySetResult(); } - private static EndPoint? NormalizeEndpoint(EndPoint? endpoint) + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the read request is transferred to the transport, or Reset returns it to the pool.")] + public void EnqueueRead() { - if (!(endpoint is IPEndPoint ep)) return endpoint; - - // Normalize endpoints - if (ep.Address.IsIPv4MappedToIPv6) + var request = _shared.MessageHandlerShared.GetReceiveMessageHandler(); + request.SetConnection(this); + if (!_transport.EnqueueRead(request)) { - return new IPEndPoint(ep.Address.MapToIPv4(), ep.Port); + request.Reset(); + StartClosing(new ConnectionClosedException()); + _startedClosing.TrySetResult(); } + } - return ep; + private async Task ProcessIncoming() + { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + EnqueueRead(); + await _startedClosing.Task.ConfigureAwait(false); } - /// - /// Handles a message receive failure. - /// - /// if the exception should not be caught and if it should be caught. - private bool HandleReceiveMessageFailure(Message? message, Exception exception) + internal void RerouteMessage(Message message, Exception? error = null) { - LogErrorExceptionReadingMessage(this.Log, exception, message, this.RemoteEndPoint, this.LocalEndPoint); + LogInformationReroutingMessage(Log, message, this); - // If deserialization completely failed, rethrow the exception so that it can be handled at another level. - if (message is null || exception is InvalidMessageFrameException) + ThreadPool.UnsafeQueueUserWorkItem(static state => { - // Returning false here informs the caller that the exception should not be caught. - return false; - } + var (connection, msg, exception) = ((Connection, Message, Exception?))state!; + connection.RetryMessage(msg, exception); + }, (this, message, error), preferLocal: true); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the response message is transferred to the local message dispatcher.")] + private void OnMessageSerializationFailure(Message message, Exception exception) + { + LogErrorExceptionSerializingMessage(Log, exception, message, this); - // The message body was not successfully decoded, but the headers were. - MessagingInstrumentation.OnRejectedMessage(message); + MessagingMetrics.OnRejectedMessage(message); if (message.HasDirection) { if (message.Direction == Message.Directions.Request) { - // Send a fast fail to the caller. - var response = this.MessageFactory.CreateResponseMessage(message); + var response = MessageFactory.CreateResponseMessage(message); response.Result = Message.ResponseTypes.Error; response.BodyObject = Response.FromException(exception); - - // Send the error response and continue processing the next message. - this.Send(response); + try + { + MessageCenter.DispatchLocalMessage(response); + } + finally + { + message.Dispose(); + } } - else if (message.Direction == Message.Directions.Response) + else if (message.Direction == Message.Directions.Response && message.RetryCount < MessagingOptions.DEFAULT_MAX_MESSAGE_SEND_RETRIES) { - // If the message was a response, propagate the exception to the intended recipient. message.Result = Message.ResponseTypes.Error; message.BodyObject = Response.FromException(exception); - this.OnReceivedMessage(message); + ++message.RetryCount; + Send(message); + } + else + { + LogWarningDroppingMessage(Log, exception, message); + MessagingMetrics.OnDroppedSentMessage(message); + message.Dispose(); } - } - - // The exception has been handled by propagating it onwards. - return true; - } - - private bool HandleSendMessageFailure(Message? message, Exception exception) - { - // We get here if we failed to serialize the msg (or any other catastrophic failure). - // Request msg fails to serialize on the sender, so we just enqueue a rejection msg. - // Response msg fails to serialize on the responding silo, so we try to send an error response back. - LogErrorExceptionSendingMessage(this.Log, exception, message, this.RemoteEndPoint, this.LocalEndPoint); - - if (message is null || exception is InvalidMessageFrameException) - { - // Returning false here informs the caller that the exception should not be caught. - return false; - } - - MessagingInstrumentation.OnFailedSentMessage(message); - - if (message.Direction == Message.Directions.Request) - { - var response = this.MessageFactory.CreateResponseMessage(message); - response.Result = Message.ResponseTypes.Error; - response.BodyObject = Response.FromException(exception); - - this.MessageCenter.DispatchLocalMessage(response); - } - else if (message.Direction == Message.Directions.Response && message.RetryCount < MessagingOptions.DEFAULT_MAX_MESSAGE_SEND_RETRIES) - { - // If we failed sending an original response, turn the response body into an error and reply with it. - // unless we have already tried sending the response multiple times. - message.Result = Message.ResponseTypes.Error; - message.BodyObject = Response.FromException(exception); - ++message.RetryCount; - - this.Send(message); } else { - LogWarningDroppingMessage( - this.Log, - exception, - message); - - MessagingInstrumentation.OnDroppedSentMessage(message); - } - - return true; - } - - private sealed class MessageHandlerPoolPolicy : PooledObjectPolicy - { - public override MessageHandler Create() => new MessageHandler(); - - public override bool Return(MessageHandler obj) - { - obj.Reset(); - return true; + message.Dispose(); } } - private sealed class MessageHandler : IThreadPoolWorkItem - { - private Message? message; - private Connection? connection; - - public void Set(Message m, Connection c) - { - this.message = m; - this.connection = c; - } - - public void Execute() - { - this.connection!.OnReceivedMessage(this.message!); - MessageHandlerPool.Return(this); - } - - public void Reset() - { - this.message = null; - this.connection = null; - } - } - - private readonly struct EndPointLogValue(EndPoint? endPoint) - { - public override string ToString() => endPoint?.ToString() ?? "(never connected)"; - } - [LoggerMessage( Level = LogLevel.Information, Message = "Closing connection {Connection}" @@ -611,18 +491,6 @@ private readonly struct EndPointLogValue(EndPoint? endPoint) )] private static partial void LogWarningExceptionProcessingIncomingMessages(ILogger logger, Exception exception, Connection connection); - [LoggerMessage( - Level = LogLevel.Warning, - Message = "Exception processing outgoing messages on connection {Connection}" - )] - private static partial void LogWarningExceptionProcessingOutgoingMessages(ILogger logger, Exception exception, Connection connection); - - [LoggerMessage( - Level = LogLevel.Warning, - Message = "Exception aborting connection {Connection}" - )] - private static partial void LogWarningExceptionAbortingConnection(ILogger logger, Exception exception, Connection connection); - [LoggerMessage( Level = LogLevel.Warning, Message = "Exception terminating connection {Connection}" @@ -631,45 +499,15 @@ private readonly struct EndPointLogValue(EndPoint? endPoint) [LoggerMessage( Level = LogLevel.Information, - Message = "Rerouting messages for remote endpoint {EndPoint}" - )] - private static partial void LogInformationReroutingMessages(ILogger logger, EndPointLogValue endPoint); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Rerouted {Count} messages for remote endpoint {EndPoint}" - )] - private static partial void LogInformationReroutedMessages(ILogger logger, int count, EndPointLogValue endPoint); - - [LoggerMessage( - Level = LogLevel.Warning, - Message = "Exception while processing messages from remote endpoint {EndPoint}" - )] - private static partial void LogWarningExceptionProcessingMessagesFromRemote(ILogger logger, Exception exception, EndPoint? endPoint); - - [LoggerMessage( - Level = LogLevel.Warning, - Message = "Exception while processing messages to remote endpoint {EndPoint}" - )] - private static partial void LogWarningExceptionProcessingMessagesToRemote(ILogger logger, Exception exception, EndPoint? endPoint); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Rerouting message {Message} from remote endpoint {EndPoint}" - )] - private static partial void LogInformationReroutingMessage(ILogger logger, Message message, EndPointLogValue endPoint); - - [LoggerMessage( - Level = LogLevel.Error, - Message = "Exception reading message {Message} from remote endpoint {Remote} to local endpoint {Local}" + Message = "Rerouting message {Message} from connection {Connection}" )] - private static partial void LogErrorExceptionReadingMessage(ILogger logger, Exception exception, Message? message, EndPoint? remote, EndPoint? local); + private static partial void LogInformationReroutingMessage(ILogger logger, Message message, Connection connection); [LoggerMessage( Level = LogLevel.Error, - Message = "Exception sending message {Message} to remote endpoint {Remote} from local endpoint {Local}" + Message = "Exception serializing message {Message} on connection {Connection}" )] - private static partial void LogErrorExceptionSendingMessage(ILogger logger, Exception exception, Message? message, EndPoint? remote, EndPoint? local); + private static partial void LogErrorExceptionSerializingMessage(ILogger logger, Exception exception, Message message, Connection connection); [LoggerMessage( EventId = (int)ErrorCode.Messaging_OutgoingMS_DroppingMessage, diff --git a/src/Orleans.Core/Networking/ConnectionBuilderDelegates.cs b/src/Orleans.Core/Networking/ConnectionBuilderDelegates.cs deleted file mode 100644 index 81cf6d64c88..00000000000 --- a/src/Orleans.Core/Networking/ConnectionBuilderDelegates.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.AspNetCore.Connections; - -namespace Orleans.Configuration -{ - internal class ConnectionBuilderDelegates - { - private readonly List> configurationDelegates = new List>(); - - public void Add(Action configure) - => this.configurationDelegates.Add(configure ?? throw new ArgumentNullException(nameof(configure))); - - public void Invoke(IConnectionBuilder builder) - { - foreach (var configureDelegate in this.configurationDelegates) - { - configureDelegate(builder); - } - } - } -} diff --git a/src/Orleans.Core/Networking/ConnectionDirection.cs b/src/Orleans.Core/Networking/ConnectionDirection.cs new file mode 100644 index 00000000000..e8587c2f59a --- /dev/null +++ b/src/Orleans.Core/Networking/ConnectionDirection.cs @@ -0,0 +1,8 @@ +namespace Orleans.Messaging; + +internal enum ConnectionDirection : byte +{ + SiloToSilo, + ClientToGateway, + GatewayToClient +} diff --git a/src/Orleans.Core/Networking/ConnectionFactory.cs b/src/Orleans.Core/Networking/ConnectionFactory.cs index f708e6ebba8..58922382046 100644 --- a/src/Orleans.Core/Networking/ConnectionFactory.cs +++ b/src/Orleans.Core/Networking/ConnectionFactory.cs @@ -1,71 +1,83 @@ using System; +using System.Collections.Generic; +using System.Net; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.Options; -using Orleans.Configuration; +using Orleans.Connections.Transport; +using Orleans.Internal; -namespace Orleans.Runtime.Messaging +namespace Orleans.Runtime.Messaging; + +internal abstract class ConnectionFactory : IDisposable, IAsyncDisposable { - internal abstract class ConnectionFactory - { - private readonly IConnectionFactory connectionFactory; - private readonly IServiceProvider serviceProvider; -#if NET9_0_OR_GREATER - private readonly Lock connectionDelegateLock = new(); -#else - private readonly object connectionDelegateLock = new(); -#endif - private ConnectionDelegate? connectionDelegate; + private readonly MessageTransportConnector _transportConnector; + private readonly AdmissionGate _connectionEstablishment = new(); + private readonly object _disposeLock = new(); + private Task? _disposeTask; - protected ConnectionFactory( - IConnectionFactory connectionFactory, - IServiceProvider serviceProvider, - IOptions connectionOptions) + protected ConnectionFactory(MessageTransportConnector transportConnector, IEnumerable middleware) + { + MessageTransportConnector connector = new BorrowedConnector(transportConnector); + foreach (var mw in middleware) { - this.connectionFactory = connectionFactory; - this.serviceProvider = serviceProvider; - this.ConnectionOptions = connectionOptions.Value; + connector = mw.Apply(connector); } - protected ConnectionOptions ConnectionOptions { get; } + _transportConnector = connector; + } - protected ConnectionDelegate ConnectionDelegate - { - get - { - if (this.connectionDelegate != null) return this.connectionDelegate; + protected abstract Connection CreateConnection(SiloAddress address, MessageTransport context); - lock (this.connectionDelegateLock) - { - if (this.connectionDelegate != null) return this.connectionDelegate; + public virtual async ValueTask ConnectAsync(SiloAddress address, CancellationToken cancellationToken) + { + using var admission = _connectionEstablishment.TryEnter(); + ObjectDisposedException.ThrowIf(!admission.Entered, this); - // Configure the connection builder using the user-defined options. - var connectionBuilder = new ConnectionBuilder(this.serviceProvider); - connectionBuilder.Use(next => - { - return context => - { - context.Features.Set(new UnderlyingConnectionTransportFeature { Transport = context.Transport }); - return next(context); - }; - }); - this.ConfigureConnectionBuilder(connectionBuilder); - Connection.ConfigureBuilder(connectionBuilder); - return this.connectionDelegate = connectionBuilder.Build(); - } - } - } + // Connect to the endpoint. + var transport = await _transportConnector.CreateAsync(GetEndPoint(address), cancellationToken).ConfigureAwait(false); - protected virtual void ConfigureConnectionBuilder(IConnectionBuilder connectionBuilder) { } + // Create a connection object to represent the connection. + try + { + return CreateConnection(address, transport); + } + catch + { + await transport.DisposeAsync().ConfigureAwait(false); + throw; + } + } - protected abstract Connection CreateConnection(SiloAddress address, ConnectionContext context); + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); - public virtual async ValueTask ConnectAsync(SiloAddress address, CancellationToken cancellationToken) + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + lock (_disposeLock) { - var connectionContext = await this.connectionFactory.ConnectAsync(address.Endpoint, cancellationToken); - var connection = this.CreateConnection(address, connectionContext); - return connection; + return new(_disposeTask ??= DisposeAsyncCore()); } } + + private async Task DisposeAsyncCore() + { + await _connectionEstablishment.CloseAsync().ConfigureAwait(false); + await _transportConnector.DisposeAsync().ConfigureAwait(false); + } + + protected abstract EndPoint GetEndPoint(SiloAddress address); + + private sealed class BorrowedConnector(MessageTransportConnector connector) : MessageTransportConnector + { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Usage", + "CA2213:Disposable fields should be disposed", + Justification = "The service provider owns the registered connector. This adapter lets the factory dispose middleware-owned decorators independently.")] + private readonly MessageTransportConnector _connector = connector; + + public override IFeatureCollection Features => _connector.Features; + public override bool IsValid => _connector.IsValid; + public override ValueTask CreateAsync(EndPoint endpoint, CancellationToken cancellationToken = default) => + _connector.CreateAsync(endpoint, cancellationToken); + } } diff --git a/src/Orleans.Core/Networking/ConnectionFrameHelper.cs b/src/Orleans.Core/Networking/ConnectionFrameHelper.cs deleted file mode 100644 index 061b9abeea2..00000000000 --- a/src/Orleans.Core/Networking/ConnectionFrameHelper.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System; -using System.Buffers; -using System.Buffers.Binary; -using System.IO.Pipelines; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; - -namespace Orleans.Runtime.Messaging -{ - /// - /// Provides framing utilities for connection middleware authors. - /// - /// Wire format per frame: [4-byte LE length] [1-byte frame type] [payload]. - /// The length field equals 1 + payload.Length. - /// - /// - /// Connection middlewares should follow the pipeline pattern - /// (like TLS) and register via SiloConnectionOptions or ClientConnectionOptions. - /// Use these helpers for structured frame I/O within your middleware. - /// - /// - /// This helper is optional. Middleware authors who need custom protocols can read/write - /// directly from context.Transport.Input (PipeReader) and context.Transport.Output - /// (PipeWriter) without using this class. - /// - /// - public static class ConnectionFrameHelper - { - /// - /// Default maximum frame length (1 MB). - /// - public const int DefaultMaxFrameLength = 1024 * 1024; - - /// - /// Prefix size: 4 bytes (LE length) + 1 byte (frame type) = 5 bytes. - /// - public const int FramePrefixSize = 5; - - /// - /// Reads a single frame from the connection. - /// - public static async ValueTask<(byte FrameType, byte[] Payload)> ReadFrameAsync( - ConnectionContext connection, - CancellationToken cancellationToken, - int maxFrameLength = DefaultMaxFrameLength) - { - var input = connection.Transport.Input; - - var readResult = await input.ReadAsync(cancellationToken); - var buffer = readResult.Buffer; - CheckCompletionWithData(ref readResult, 4); - - while (buffer.Length < 4) - { - input.AdvanceTo(buffer.Start, buffer.End); - readResult = await input.ReadAsync(cancellationToken); - buffer = readResult.Buffer; - CheckCompletionWithData(ref readResult, 4); - } - - var lengthBytes = new byte[4]; - buffer.Slice(0, 4).CopyTo(lengthBytes); - var frameLength = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes); - - if (frameLength < 1) - throw new InvalidOperationException($"Invalid frame length: {frameLength}. Minimum is 1 (frame type byte)."); - if (frameLength > maxFrameLength) - throw new InvalidOperationException($"Frame length {frameLength} exceeds maximum allowed length of {maxFrameLength}."); - - input.AdvanceTo(buffer.GetPosition(4)); - - var frameBytes = new byte[frameLength]; - var bytesRead = 0; - while (bytesRead < frameLength) - { - readResult = await input.ReadAsync(cancellationToken); - buffer = readResult.Buffer; - - if (readResult.IsCanceled) - { - input.AdvanceTo(buffer.Start, buffer.End); - throw new InvalidOperationException("Connection terminated during frame exchange."); - } - - var bytesToCopy = (int)Math.Min(buffer.Length, frameLength - bytesRead); - buffer.Slice(0, bytesToCopy).CopyTo(frameBytes.AsSpan(bytesRead, bytesToCopy)); - bytesRead += bytesToCopy; - input.AdvanceTo(buffer.GetPosition(bytesToCopy)); - - if (readResult.IsCompleted && bytesRead < frameLength) - { - throw new InvalidOperationException("Connection terminated during frame exchange."); - } - } - - byte frameType = frameBytes[0]; - byte[] payload; - if (frameLength > 1) - { - payload = new byte[frameLength - 1]; - Array.Copy(frameBytes, 1, payload, 0, payload.Length); - } - else - { - payload = Array.Empty(); - } - - return (frameType, payload); - } - - /// - /// Writes a frame with the given type and raw payload bytes. - /// - public static async ValueTask WriteFrameAsync( - ConnectionContext connection, - byte frameType, - byte[] payload, - CancellationToken cancellationToken) - { - WriteFrameToOutput(connection.Transport.Output, frameType, payload); - await FlushFrameAsync(connection.Transport.Output, cancellationToken); - } - - /// - /// Writes a frame using zero-copy framing via . - /// The delegate writes payload directly into the transport pipe buffer. - /// - public static async ValueTask WriteFrameAsync( - ConnectionContext connection, - byte frameType, - Action> writePayload, - CancellationToken cancellationToken) - { - WriteFrameWithPrefixingWriter(connection.Transport.Output, frameType, writePayload, MemoryPool.Shared); - await FlushFrameAsync(connection.Transport.Output, cancellationToken); - } - - private static async ValueTask FlushFrameAsync(PipeWriter output, CancellationToken cancellationToken) - { - var flushResult = await output.FlushAsync(cancellationToken); - if (flushResult.IsCanceled) - throw new OperationCanceledException("Flush canceled while writing frame."); - if (flushResult.IsCompleted) - throw new InvalidOperationException("Connection terminated while writing frame."); - } - - /// - /// Writes a length-prefixed UTF-8 string (4-byte LE length + bytes) into a buffer. - /// - public static void WriteLengthPrefixedString(IBufferWriter writer, string value) - { - var bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); - - var lengthSpan = writer.GetSpan(4); - BinaryPrimitives.WriteInt32LittleEndian(lengthSpan, bytes.Length); - writer.Advance(4); - - if (bytes.Length > 0) - { - var dataSpan = writer.GetSpan(bytes.Length); - bytes.CopyTo(dataSpan); - writer.Advance(bytes.Length); - } - } - - /// - /// Reads a length-prefixed UTF-8 string from a payload byte array. - /// - public static string ReadLengthPrefixedString(byte[] data, ref int offset) - { - if ((uint)offset > (uint)data.Length || data.Length - offset < sizeof(int)) - throw new InvalidOperationException("Not enough data to read string length."); - - var length = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset, sizeof(int))); - offset += sizeof(int); - - if (length < 0) - throw new InvalidOperationException($"Invalid string length: {length}."); - if (length == 0) - return string.Empty; - if (length > data.Length - offset) - throw new InvalidOperationException($"Not enough data to read string of length {length}. Available: {data.Length - offset}."); - - var result = Encoding.UTF8.GetString(data, offset, length); - offset += length; - return result; - } - - private static void WriteFrameToOutput(PipeWriter output, byte frameType, byte[] payload) - { - var payloadLength = payload?.Length ?? 0; - var frameLength = 1 + payloadLength; - - Span prefix = stackalloc byte[FramePrefixSize]; - BinaryPrimitives.WriteInt32LittleEndian(prefix, frameLength); - prefix[4] = frameType; - output.Write(prefix); - - if (payloadLength > 0) - { - output.Write(payload); - } - } - - private static void WriteFrameWithPrefixingWriter( - PipeWriter output, - byte frameType, - Action> writeBody, - MemoryPool memoryPool) - { - using var prefixWriter = new PrefixingBufferWriter(FramePrefixSize, 256, memoryPool); - prefixWriter.Init(output); - - try - { - writeBody?.Invoke(prefixWriter); - - var payloadLength = prefixWriter.CommittedBytes; - var frameLength = 1 + payloadLength; - Span prefix = stackalloc byte[FramePrefixSize]; - BinaryPrimitives.WriteInt32LittleEndian(prefix, frameLength); - prefix[4] = frameType; - - prefixWriter.Complete(prefix); - } - finally - { - prefixWriter.Reset(); - } - } - - private static void CheckCompletionWithData(ref ReadResult result, long required) - { - if (result.IsCanceled) - throw new InvalidOperationException("Connection terminated during frame exchange."); - if (result.IsCompleted && result.Buffer.Length < required) - throw new InvalidOperationException("Connection terminated during frame exchange."); - } - } -} diff --git a/src/Orleans.Core/Networking/ConnectionManager.cs b/src/Orleans.Core/Networking/ConnectionManager.cs index 23fa1c7d4cd..23a96de53a6 100644 --- a/src/Orleans.Core/Networking/ConnectionManager.cs +++ b/src/Orleans.Core/Networking/ConnectionManager.cs @@ -6,10 +6,10 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Orleans.Configuration; +using Orleans.Connections.Transport; using Orleans.Core.Diagnostics; using Orleans.Internal; @@ -464,7 +464,7 @@ private async Task RunConnectionAsync(SiloAddress address, Connection connection { using (this.BeginConnectionScope(connection)) { - await connection.Run(); + await connection.RunAsync(); } } catch (Exception exception) diff --git a/src/Orleans.Core/Networking/ConnectionMiddlewareExtensions.cs b/src/Orleans.Core/Networking/ConnectionMiddlewareExtensions.cs deleted file mode 100644 index 02e22599cba..00000000000 --- a/src/Orleans.Core/Networking/ConnectionMiddlewareExtensions.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.DependencyInjection; -using Orleans.Runtime.Messaging; - -namespace Orleans -{ - /// - /// Extension methods for registering in the connection pipeline. - /// - public static class ConnectionMiddlewareExtensions - { - /// - /// Adds an to the connection pipeline. - /// The middleware must be registered as a singleton service and must be safe for concurrent use. - /// - /// The middleware type implementing . - public static IConnectionBuilder UseMiddleware(this IConnectionBuilder builder) where T : IConnectionMiddleware - { - builder.Use(next => - { - var middleware = builder.ApplicationServices.GetRequiredService(); - return context => middleware.OnConnectionAsync(context, next); - }); - - return builder; - } - - /// - /// Adds an instance to the connection pipeline. - /// - /// - /// The instance is shared by all connections and must be safe for concurrent use. - /// The caller owns the instance and is responsible for disposing it. - /// - public static IConnectionBuilder UseMiddleware(this IConnectionBuilder builder, IConnectionMiddleware middleware) - { - if (middleware is null) - { - throw new ArgumentNullException(nameof(middleware)); - } - - builder.Use(next => - { - return context => middleware.OnConnectionAsync(context, next); - }); - - return builder; - } - } -} diff --git a/src/Orleans.Core/Networking/ConnectionOptions.cs b/src/Orleans.Core/Networking/ConnectionOptions.cs index c12b1422c2a..383ff6b4353 100644 --- a/src/Orleans.Core/Networking/ConnectionOptions.cs +++ b/src/Orleans.Core/Networking/ConnectionOptions.cs @@ -32,5 +32,16 @@ public class ConnectionOptions /// The default value for . /// public static readonly TimeSpan DEFAULT_OPENCONNECTION_TIMEOUT = TimeSpan.FromSeconds(5); + + /// + /// Gets or sets the timeout for gracefully closing a connection. + /// If the timeout is exceeded, the connection will be forcefully closed. + /// + public TimeSpan CloseConnectionTimeout { get; set; } = DEFAULT_CLOSECONNECTION_TIMEOUT; + + /// + /// The default value for . + /// + public static readonly TimeSpan DEFAULT_CLOSECONNECTION_TIMEOUT = TimeSpan.FromSeconds(30); } } diff --git a/src/Orleans.Core/Networking/ConnectionPreamble.cs b/src/Orleans.Core/Networking/ConnectionPreamble.cs index c081b9eb82d..d148a13dd1a 100644 --- a/src/Orleans.Core/Networking/ConnectionPreamble.cs +++ b/src/Orleans.Core/Networking/ConnectionPreamble.cs @@ -1,10 +1,12 @@ using System; using System.Buffers; using System.Buffers.Binary; -using System.IO.Pipelines; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; +using Orleans.Connections.Transport; using Orleans.Serialization; +using Orleans.Serialization.Buffers; +using Orleans.Serialization.Buffers.Adaptors; +using Orleans.Serialization.Session; namespace Orleans.Runtime.Messaging { @@ -28,98 +30,148 @@ internal sealed class ConnectionPreambleHelper { private const int MaxPreambleLength = 1024; private readonly Serializer _preambleSerializer; - public ConnectionPreambleHelper(Serializer preambleSerializer) + private readonly SerializerSessionPool _serializerSessionPool; + + public ConnectionPreambleHelper(Serializer preambleSerializer, SerializerSessionPool serializerSessionPool) { _preambleSerializer = preambleSerializer; + _serializerSessionPool = serializerSessionPool; } - internal async ValueTask Write(ConnectionContext connection, ConnectionPreamble preamble) + internal async ValueTask Write(MessageTransport transport, ConnectionPreamble preamble) { - var output = connection.Transport.Output; - using var outputWriter = new PrefixingBufferWriter(sizeof(int), 1024, MemoryPool.Shared); - outputWriter.Init(output); - _preambleSerializer.Serialize( - preamble, - outputWriter); - - var length = outputWriter.CommittedBytes; - - if (length > MaxPreambleLength) + using var writeRequest = PreambleWriteRequest.Create(preamble, _preambleSerializer, _serializerSessionPool); + if (!transport.EnqueueWrite(writeRequest)) { - throw new InvalidOperationException($"Created preamble of length {length}, which is greater than maximum allowed size of {MaxPreambleLength}."); + throw new ConnectionAbortedException(); } - WriteLength(outputWriter, length); - - var flushResult = await output.FlushAsync(); - if (flushResult.IsCanceled) - { - throw new OperationCanceledException("Flush canceled"); - } + await writeRequest.Completion; return; } - private static void WriteLength(PrefixingBufferWriter outputWriter, int length) + internal async ValueTask Read(MessageTransport transport) { - Span lengthSpan = stackalloc byte[4]; - BinaryPrimitives.WriteInt32LittleEndian(lengthSpan, length); - outputWriter.Complete(lengthSpan); + using var readRequest = PreambleReadRequest.Create(_preambleSerializer); + if (!transport.EnqueueRead(readRequest)) + { + throw new ConnectionAbortedException(); + } + + var result = await readRequest.Completion; + return result; } - internal async ValueTask Read(ConnectionContext connection) + private sealed class PreambleWriteRequest : WriteRequest, IDisposable { - var input = connection.Transport.Input; + private readonly TaskCompletionSource _completion = new(); + private readonly ArcBufferWriter _buffer; - var readResult = await input.ReadAsync(); - var buffer = readResult.Buffer; - CheckForCompletion(ref readResult); - while (buffer.Length < 4) + private PreambleWriteRequest(ArcBufferWriter buffer) { - input.AdvanceTo(buffer.Start, buffer.End); - readResult = await input.ReadAsync(); - buffer = readResult.Buffer; - CheckForCompletion(ref readResult); + _buffer = buffer; + Buffers = new(_buffer); } - int ReadLength(ref ReadOnlySequence b) + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the buffer is transferred to the returned preamble write request.")] + public static PreambleWriteRequest Create(ConnectionPreamble preamble, Serializer preambleSerializer, SerializerSessionPool serializerSessionPool) { - Span lengthBytes = stackalloc byte[4]; - b.Slice(0, 4).CopyTo(lengthBytes); - b = b.Slice(4); - return BinaryPrimitives.ReadInt32LittleEndian(lengthBytes); + var buffer = new ArcBufferWriter(); + try + { + // Reserve space for framing + var framingBytes = buffer.GetSpan(sizeof(int)); + buffer.AdvanceWriter(sizeof(int)); + + // Serialize the preamble. + using var session = serializerSessionPool.GetSession(); + var writer = Writer.Create(buffer, session); + preambleSerializer.Serialize(preamble, ref writer); + + // Write framing + var length = writer.Position; + BinaryPrimitives.WriteInt32LittleEndian(framingBytes, length); + + if (length > MaxPreambleLength) + { + throw new InvalidOperationException($"Created preamble of length {length}, which is greater than maximum allowed size of {MaxPreambleLength}."); + } + + return new(buffer); + } + catch + { + buffer.Dispose(); + throw; + } } - var length = ReadLength(ref buffer); - if (length > MaxPreambleLength) - { - throw new InvalidOperationException($"Remote connection sent preamble length of {length}, which is greater than maximum allowed size of {MaxPreambleLength}."); - } + public override void SetResult() => _completion.SetResult(); + public override void SetException(Exception error) => _completion.SetException(error); - while (buffer.Length < length) - { - input.AdvanceTo(buffer.Start, buffer.End); - readResult = await input.ReadAsync(); - buffer = readResult.Buffer; - CheckForCompletion(ref readResult); - } + public void Dispose() => _buffer.Dispose(); - var payloadBuffer = buffer.Slice(0, length); + public Task Completion => _completion.Task; + } - try - { - // A valid connection preamble payload always deserializes to an instance. - var preamble = _preambleSerializer.Deserialize(payloadBuffer)!; - return preamble; - } - finally + private sealed class PreambleReadRequest : ReadRequest, IDisposable + { + private readonly Serializer _preambleSerializer; + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _preambleLength = -1; + + private PreambleReadRequest(Serializer preambleSerializer) { - input.AdvanceTo(payloadBuffer.End); + _preambleSerializer = preambleSerializer; } - void CheckForCompletion(ref ReadResult r) + public Task Completion => _completion.Task; + + public static PreambleReadRequest Create(Serializer preambleSerializer) => new(preambleSerializer); + + public void Dispose() { } + public override void OnError(Exception error) => _completion.SetException(error); + public override void OnCanceled() => _completion.SetException(new OperationCanceledException("Read operation canceled")); + public override bool OnRead(ArcBufferReader buffer) { - if (r.IsCanceled || r.IsCompleted) throw new InvalidOperationException("Connection terminated prematurely"); + if (buffer.Length < sizeof(int)) + { + return false; + } + + if (_preambleLength < 0) + { + Span preambleBytes = stackalloc byte[sizeof(int)]; + var preambleBuffer = buffer.Peek(in preambleBytes); + _preambleLength = BinaryPrimitives.ReadInt32LittleEndian(preambleBuffer); + + if (_preambleLength > MaxPreambleLength) + { + throw new InvalidOperationException($"Read preamble length of {_preambleLength}, which is greater than maximum allowed size of {MaxPreambleLength}."); + } + + if (_preambleLength <= 0) + { + throw new InvalidOperationException($"Read preamble length of {_preambleLength}, which is less than or equal to zero."); + } + } + + if (buffer.Length >= _preambleLength + sizeof(int)) + { + buffer.Skip(sizeof(int)); + using var preambleBuffer = buffer.ConsumeSlice(_preambleLength); + var preamble = _preambleSerializer.Deserialize(preambleBuffer) + ?? throw new InvalidOperationException("Received a null connection preamble."); + _completion.SetResult(preamble); + + return true; + } + + return false; } } } diff --git a/src/Orleans.Core/Networking/ConnectionShared.cs b/src/Orleans.Core/Networking/ConnectionShared.cs index 689a880f372..d4db8434b33 100644 --- a/src/Orleans.Core/Networking/ConnectionShared.cs +++ b/src/Orleans.Core/Networking/ConnectionShared.cs @@ -1,24 +1,38 @@ using System; -using Microsoft.Extensions.Logging; -using Orleans.Placement.Repartitioning; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Connections; -namespace Orleans.Runtime.Messaging +namespace Orleans.Runtime.Messaging; + +internal sealed class ConnectionCommon( + IServiceProvider serviceProvider, + MessageFactory messageFactory, + MessagingTrace messagingTrace, + ConnectionTrace networkingTrace, + MessagingInstruments messagingInstruments, + NetworkingInstruments networkingInstruments, + Orleans.Placement.Repartitioning.IMessageStatisticsSink messageStatisticsSink) { - internal sealed class ConnectionCommon( - IServiceProvider serviceProvider, - MessageFactory messageFactory, - MessagingTrace messagingTrace, - OrleansInstruments orleansInstruments, - MessagingInstruments messagingInstruments, - ILogger logger, - IMessageStatisticsSink messageStatisticsSink) + private readonly object _lock = new(); + private volatile MessageHandlerShared? _messageHandlerShared; + + public MessageFactory MessageFactory { get; } = messageFactory; + public IServiceProvider ServiceProvider { get; } = serviceProvider; + public ConnectionTrace ConnectionTrace { get; } = networkingTrace; + public MessagingTrace MessagingTrace { get; } = messagingTrace; + public MessagingInstruments MessagingInstruments { get; } = messagingInstruments; + public NetworkingInstruments NetworkingInstruments { get; } = networkingInstruments; + public Action? MessageObserver { get; } = messageStatisticsSink.GetMessageObserver(); + + public MessageHandlerShared MessageHandlerShared { - public MessageFactory MessageFactory { get; } = messageFactory; - public IServiceProvider ServiceProvider { get; } = serviceProvider; - public NetworkingInstruments NetworkingInstruments { get; } = new(orleansInstruments); - public ILogger Logger { get; } = logger; - public IMessageStatisticsSink MessageStatisticsSink { get; } = messageStatisticsSink; - public MessagingTrace MessagingTrace { get; } = messagingTrace; - public MessagingInstruments MessagingInstruments { get; } = messagingInstruments; + get + { + if (_messageHandlerShared is { } value) return value; + lock (_lock) + { + return _messageHandlerShared ??= ServiceProvider.GetRequiredService(); + } + } } } diff --git a/src/Orleans.Core/Networking/ConnectionTrace.cs b/src/Orleans.Core/Networking/ConnectionTrace.cs new file mode 100644 index 00000000000..5f9c3e48aba --- /dev/null +++ b/src/Orleans.Core/Networking/ConnectionTrace.cs @@ -0,0 +1,32 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging; + +namespace Orleans.Connections; + +internal sealed class ConnectionTrace : DiagnosticListener, ILogger +{ + private readonly ILogger _log; + + public ConnectionTrace(ILoggerFactory loggerFactory) : base(typeof(ConnectionTrace).FullName!) + { + _log = loggerFactory.CreateLogger("Orleans.Connections"); + } + + public IDisposable? BeginScope(TState state) where TState : notnull + { + return _log.BeginScope(state); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsEnabled(LogLevel logLevel) + { + return _log.IsEnabled(logLevel); + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + _log.Log(logLevel, eventId, state, exception, formatter); + } +} diff --git a/src/Orleans.Core/Networking/CorrelationIdGenerator.cs b/src/Orleans.Core/Networking/CorrelationIdGenerator.cs new file mode 100644 index 00000000000..2b944b6e4fe --- /dev/null +++ b/src/Orleans.Core/Networking/CorrelationIdGenerator.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; + +namespace Orleans.Runtime.Messaging; + +internal static class CorrelationIdGenerator +{ + // Base32 encoding - in ascii sort order for easy text based sorting + private static readonly char[] s_encode32Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUV".ToCharArray(); + + // Seed the _lastConnectionId for this application instance with + // the number of 100-nanosecond intervals that have elapsed since 12:00:00 midnight, January 1, 0001 + // for a roughly increasing _lastId over restarts + private static long _lastId = DateTime.UtcNow.Ticks; + + public static string GetNextId() => GenerateId(Interlocked.Increment(ref _lastId)); + + private static string GenerateId(long id) + { + return string.Create(13, id, (buffer, value) => + { + var encode32Chars = s_encode32Chars; + + buffer[12] = encode32Chars[value & 31]; + buffer[11] = encode32Chars[value >> 5 & 31]; + buffer[10] = encode32Chars[value >> 10 & 31]; + buffer[9] = encode32Chars[value >> 15 & 31]; + buffer[8] = encode32Chars[value >> 20 & 31]; + buffer[7] = encode32Chars[value >> 25 & 31]; + buffer[6] = encode32Chars[value >> 30 & 31]; + buffer[5] = encode32Chars[value >> 35 & 31]; + buffer[4] = encode32Chars[value >> 40 & 31]; + buffer[3] = encode32Chars[value >> 45 & 31]; + buffer[2] = encode32Chars[value >> 50 & 31]; + buffer[1] = encode32Chars[value >> 55 & 31]; + buffer[0] = encode32Chars[value >> 60 & 31]; + }); + } +} diff --git a/src/Orleans.Core/Networking/IConnectionMiddleware.cs b/src/Orleans.Core/Networking/IConnectionMiddleware.cs deleted file mode 100644 index ecfc82eadf0..00000000000 --- a/src/Orleans.Core/Networking/IConnectionMiddleware.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; - -namespace Orleans.Runtime.Messaging -{ - /// - /// Interface for connection middleware. - /// - /// Implementers provide and invoke the supplied - /// to continue the pipeline. - /// - /// - /// Middleware instances can be invoked concurrently for multiple connections. - /// Per-connection state should be stored in local variables or on the - /// . - /// - /// - /// Register via builder.UseMiddleware() or manually with - /// builder.Use(next => ctx => middleware.OnConnectionAsync(ctx, next)). - /// - /// - public interface IConnectionMiddleware - { - /// - /// Called when a connection is established. Implementations should call - /// to continue the pipeline after performing their work. - /// - /// The connection context. - /// The next delegate in the connection pipeline. - Task OnConnectionAsync(ConnectionContext context, ConnectionDelegate next); - } -} diff --git a/src/Orleans.Core/Networking/IUnderlyingTransportFeature.cs b/src/Orleans.Core/Networking/IUnderlyingTransportFeature.cs deleted file mode 100644 index a7911cdb64a..00000000000 --- a/src/Orleans.Core/Networking/IUnderlyingTransportFeature.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.IO.Pipelines; - -namespace Orleans.Runtime.Messaging -{ - /// - /// Holds the underlying transport used by a connection. - /// - internal interface IUnderlyingTransportFeature - { - /// - /// Gets the underlying transport. - /// - IDuplexPipe Transport { get; } - } - - /// - /// Holds the underlying transport used by a connection. - /// - internal class UnderlyingConnectionTransportFeature : IUnderlyingTransportFeature - { - /// - public IDuplexPipe Transport { get; set; } = null!; - } -} diff --git a/src/Orleans.Core/Networking/MessageHandlerShared.cs b/src/Orleans.Core/Networking/MessageHandlerShared.cs new file mode 100644 index 00000000000..db434ff41e7 --- /dev/null +++ b/src/Orleans.Core/Networking/MessageHandlerShared.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Connections; + +namespace Orleans.Runtime.Messaging; + +internal delegate MessageSerializer MessageSerializerFactory(); + +internal sealed class MessageHandlerShared( + MessagingTrace messagingTrace, + ConnectionTrace connectionTrace, + MessageSerializerFactory serializerFactory, + MessageFactory messageFactory, + IMessageCenter messageCenter, + MessagingInstruments messagingInstruments) : IDisposable +{ + private readonly MessageSerializerFactory _serializerFactory = serializerFactory; + private readonly ConcurrentQueue _serializerPool = new(); + private readonly ConcurrentQueue _receivePool = new(); + private readonly ConcurrentQueue _sendPool = new(); + private readonly object _poolLock = new(); + private bool _disposing; + private volatile bool _disposed; + private int _activeSerializers; + private int _activeSendWorkItems; + + public MessagingTrace MessagingTrace { get; } = messagingTrace; + public ConnectionTrace ConnectionTrace { get; } = connectionTrace; + public MessageFactory MessageFactory { get; } = messageFactory; + public IMessageCenter MessageCenter { get; } = messageCenter; + public MessagingInstruments MessagingInstruments { get; } = messagingInstruments; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal MessageSerializer GetMessageSerializer() + { + lock (_poolLock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _activeSerializers++; + if (_serializerPool.TryDequeue(out var result)) + { + return result; + } + + try + { + return _serializerFactory(); + } + catch + { + _activeSerializers--; + throw; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Return(MessageSerializer serializer) + { + lock (_poolLock) + { + _activeSerializers--; + if (_disposing) + { + serializer.Dispose(); + } + else + { + _serializerPool.Enqueue(serializer); + } + + SignalIfQuiescent(); + } + } + + internal bool TryAcquireSendWork() + { + lock (_poolLock) + { + if (_disposing) + { + return false; + } + + _activeSendWorkItems++; + return true; + } + } + + internal void ReleaseSendWork() + { + lock (_poolLock) + { + _activeSendWorkItems--; + SignalIfQuiescent(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal MessageReadRequest GetReceiveMessageHandler() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_receivePool.TryDequeue(out var result)) + { + return result; + } + + return new(this); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Return(MessageReadRequest handler) + { + lock (_poolLock) + { + if (!_disposing) + { + _receivePool.Enqueue(handler); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal MessageWriteRequest GetSendMessageHandler() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_sendPool.TryDequeue(out var result)) + { + return result; + } + + return new(this); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal MessageWriteRequest GetSendMessageHandler(Connection connection) + { + var result = GetSendMessageHandler(); + result.Initialize(connection); + return result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Return(MessageWriteRequest handler) + { + lock (_poolLock) + { + if (_disposing) + { + handler.Dispose(); + } + else + { + _sendPool.Enqueue(handler); + } + } + } + + public void Dispose() + { + lock (_poolLock) + { + if (_disposed) + { + return; + } + + if (_disposing) + { + while (!_disposed) + { + Monitor.Wait(_poolLock); + } + + return; + } + + _disposing = true; + while (_serializerPool.TryDequeue(out var serializer)) + { + serializer.Dispose(); + } + + while (_sendPool.TryDequeue(out var handler)) + { + handler.Dispose(); + } + + _receivePool.Clear(); + while (_activeSerializers > 0 || _activeSendWorkItems > 0) + { + Monitor.Wait(_poolLock); + } + + _disposed = true; + Monitor.PulseAll(_poolLock); + } + } + + private void SignalIfQuiescent() + { + if (_activeSerializers == 0 && _activeSendWorkItems == 0) + { + Monitor.PulseAll(_poolLock); + } + } +} diff --git a/src/Orleans.Core/Networking/MessageReadRequest.cs b/src/Orleans.Core/Networking/MessageReadRequest.cs new file mode 100644 index 00000000000..d3056bdf70d --- /dev/null +++ b/src/Orleans.Core/Networking/MessageReadRequest.cs @@ -0,0 +1,228 @@ +using System; +using System.Threading; +using Microsoft.Extensions.Logging; +using Orleans.Serialization.Invocation; +using Orleans.Serialization.Buffers; +using System.Buffers.Binary; +using Orleans.Connections.Transport; +using System.Diagnostics; + +namespace Orleans.Runtime.Messaging; + +internal sealed partial class MessageReadRequest(MessageHandlerShared shared) : ReadRequest, IThreadPoolWorkItem, IDisposable +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Usage", + "CA2213:Disposable fields should be disposed", + Justification = "MessageHandlerShared owns and pools this request; the request does not own the shared pool.")] + internal readonly MessageHandlerShared Shared = shared; + + private Connection? _connection; + private int _headerLength; + private int _bodyLength; + internal ArcBuffer _headers; + private ArcBuffer _body; + + public int PayloadLength => _headerLength + _bodyLength; + + internal Message.ResponseTypes _originalResponseType; + public ref ArcBuffer Headers => ref _headers; + public ref ArcBuffer Body => ref _body; + public int HeaderLength => _headerLength; + public int BodyLength => _bodyLength; + + public void SetConnection(Connection connection) + { + Debug.Assert(_connection is null); + _connection = connection; + } + + public void Reset() + { + _headerLength = default; + _bodyLength = default; + _originalResponseType = default; + _connection = default; + _headers.Dispose(); + _body.Dispose(); + _headers = default; + _body = default; + Shared.Return(this); + } + + internal void ReleaseHeaders() + { + _headers.Dispose(); + _headers = default; + } + + public override void OnError(Exception error) + { + var connection = _connection ?? throw new InvalidOperationException("Cannot report read failure before a connection is set."); + Reset(); + connection.OnReadCompleted(error); + } + + public override void OnCanceled() + { + OnError(new OperationCanceledException()); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The serializer is borrowed from and returned to MessageHandlerShared.")] + public override bool OnRead(ArcBufferReader bufferReader) + { + Debug.Assert(_connection is not null); + + if (bufferReader.Length < Message.LENGTH_HEADER_SIZE) + { + return false; + } + + if (_headerLength == 0 && _bodyLength == 0) + { + Span scratch = stackalloc byte[Message.LENGTH_HEADER_SIZE]; + var lengthBytes = bufferReader.Peek(in scratch); + _headerLength = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes); + _bodyLength = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes[sizeof(int)..]); + var messageSerializer = Shared.GetMessageSerializer(); + try + { + messageSerializer.ValidateFrameLengths(_headerLength, _bodyLength); + } + finally + { + Shared.Return(messageSerializer); + } + + bufferReader.Skip(Message.LENGTH_HEADER_SIZE); + } + + if (bufferReader.Length < PayloadLength) + { + return false; + } + + _headers = bufferReader.ConsumeSlice(_headerLength); + _body = bufferReader.ConsumeSlice(_bodyLength); + Debug.Assert(_headers.Length == _headerLength); + Debug.Assert(_body.Length == _bodyLength); + + _connection.EnqueueRead(); + ThreadPool.UnsafeQueueUserWorkItem(this, preferLocal: false); + return true; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The decoded message is transferred to the receiver; failure paths dispose it or transfer it to an error response.")] + void IThreadPoolWorkItem.Execute() + { + Message? message = null; + var connection = _connection ?? throw new InvalidOperationException("Cannot process a message before a connection is set."); + var shouldReset = true; + MessageSerializer? messageSerializer = null; + try + { + messageSerializer = Shared.GetMessageSerializer(); + messageSerializer.ReadHeaders(this, out message); + connection.MarkMessageReceived(); + connection.RecordMessageReceive(message, PayloadLength, HeaderLength); + + // Body deserialization is more likely to fail than header deserialization. + // Separating the two allows for these kinds of errors to be propagated back to the caller. + if (_bodyLength > 0) + { + // This instance is owned by the message now, so it will not be reset immediately. + message.SetMessageReadRequest(this); + shouldReset = false; + } + connection.OnReceivedMessage(message); + } + catch (Exception exception) + { + try + { + HandleReceiveMessageFailure(message, exception); + } + catch (Exception fatalException) + { + if (!shouldReset) + { + message?.Dispose(); + } + + connection.OnReadCompleted(new AggregateException(exception, fatalException)); + } + } + finally + { + if (shouldReset) + { + Reset(); + } + + if (messageSerializer is not null) + { + Shared.Return(messageSerializer); + } + } + + void HandleReceiveMessageFailure(Message? message, Exception exception) + { + if (message is null) + { + LogExceptionReadingConnection(Shared.ConnectionTrace, exception, connection); + + connection.OnReadCompleted(exception); + return; + } + + LogExceptionReadingMessage(Shared.ConnectionTrace, exception, message, connection); + + // The message body was not successfully decoded, but the headers were. + Shared.MessagingInstruments.OnRejectedMessage(message); + + if (message.HasDirection) + { + if (message.Direction == Message.Directions.Request) + { + // Send a fast fail to the caller. + var response = Shared.MessageFactory.CreateResponseMessage(message); + response.Result = Message.ResponseTypes.Error; + response.BodyObject = Response.FromException(exception); + + // Send the error response and continue processing the next message. + connection.Send(response); + message.Dispose(); + } + else if (message.Direction == Message.Directions.Response) + { + // If the message was a response, propagate the exception to the intended recipient. + message.Result = Message.ResponseTypes.Error; + message.BodyObject = Response.FromException(exception); + Shared.MessageCenter.DispatchLocalMessage(message); + } + else + { + message.Dispose(); + } + } + else + { + message.Dispose(); + } + + } + } + public void Dispose() => Reset(); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Exception reading message from connection {Connection}")] + private static partial void LogExceptionReadingConnection(ILogger logger, Exception exception, Connection connection); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Exception reading message {Message} from connection {Connection}")] + private static partial void LogExceptionReadingMessage(ILogger logger, Exception exception, Message message, Connection connection); +} diff --git a/src/Orleans.Core/Networking/MessageWriteRequest.cs b/src/Orleans.Core/Networking/MessageWriteRequest.cs new file mode 100644 index 00000000000..9377842015b --- /dev/null +++ b/src/Orleans.Core/Networking/MessageWriteRequest.cs @@ -0,0 +1,138 @@ +using System; +using Orleans.Serialization.Buffers; +using System.Buffers.Binary; +using Orleans.Connections.Transport; +using Microsoft.Extensions.Logging; +using System.Collections.Generic; + +namespace Orleans.Runtime.Messaging; + +internal sealed partial class MessageWriteRequest : WriteRequest, IDisposable +{ + private const int LargeMessageSize = 8 * 1024; + private const int SendPageSize = 32 * 1024; + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Usage", + "CA2213:Disposable fields should be disposed", + Justification = "MessageHandlerShared owns and pools this request; the request does not own the shared pool.")] + private readonly MessageHandlerShared _shared; + private readonly ArcBufferWriter _buffer = new(); + private readonly List<(Message Message, int TotalLength, int HeaderLength)> _messages = []; + private Connection? _connection; + private MessageSerializer? _messageSerializer; + private bool _hasLargeMessages; + private bool _disposed; + + public MessageWriteRequest(MessageHandlerShared shared) + { + _shared = shared; + Buffers = new(_buffer); + } + + public int MessageCount => _messages.Count; + public int Length => _buffer.Length; + internal override bool HasLargeMessages => _hasLargeMessages; + + public void Initialize(Connection connection) => _connection = connection; + public Message GetMessage(int index) => _messages[index].Message; + + public void WriteMessage(Message message) + { + var startLength = _buffer.Length; + var messageSerializer = _messageSerializer ??= _shared.GetMessageSerializer(); + try + { + // Reserve space for framing + var framingBytes = _buffer.GetSpan(Message.LENGTH_HEADER_SIZE); + _buffer.AdvanceWriter(Message.LENGTH_HEADER_SIZE); + + // Serialize the message in full + var (headerLength, bodyLength) = messageSerializer.Write(_buffer, message); + + // Write the framing + BinaryPrimitives.WriteInt32LittleEndian(framingBytes, headerLength); + BinaryPrimitives.WriteInt32LittleEndian(framingBytes[sizeof(int)..], bodyLength); + + var totalLength = headerLength + bodyLength; + _messages.Add((message, totalLength, headerLength)); + _hasLargeMessages |= totalLength >= LargeMessageSize; + } + catch + { + _buffer.Truncate(startLength); + throw; + } + } + + public void CompleteWriting() + { + if (_messageSerializer is { } serializer) + { + _messageSerializer = null; + _shared.Return(serializer); + } + } + + public override void SetResult() + { + try + { + var connection = _connection ?? throw new InvalidOperationException("The write request has no owning connection."); + foreach (var (message, totalLength, headerLength) in _messages) + { + connection.RecordMessageSend(message, totalLength, headerLength); + } + } + finally + { + foreach (var (message, _, _) in _messages) + { + message.ReleaseBodyBuffer(); + } + + Reset(); + } + } + + public override void SetException(Exception error) + { + LogErrorSendingMessages(_shared.ConnectionTrace, error, _messages); + var connection = _connection ?? throw new InvalidOperationException("The write request has no owning connection."); + foreach (var (message, _, _) in _messages) + { + connection.RerouteMessage(message, error); + } + + Reset(); + } + + public void Reset() + { + var nextPageSize = _messages.Count == 1 + && _messages[0].TotalLength is >= LargeMessageSize and < SendPageSize + ? SendPageSize + : 0; + CompleteWriting(); + _messages.Clear(); + _hasLargeMessages = false; + _buffer.Reset(nextPageSize); + _connection = null; + _shared.Return(this); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + CompleteWriting(); + _messages.Clear(); + _buffer.Dispose(); + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Error sending messages {Messages}")] + private static partial void LogErrorSendingMessages(ILogger logger, Exception error, object messages); +} diff --git a/src/Orleans.Core/Networking/Shared/BufferExtensions.cs b/src/Orleans.Core/Networking/Shared/BufferExtensions.cs deleted file mode 100644 index cbd234f8079..00000000000 --- a/src/Orleans.Core/Networking/Shared/BufferExtensions.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Runtime.InteropServices; - -namespace Orleans.Networking.Shared -{ - internal static class BufferExtensions - { - public static ArraySegment GetArray(this Memory memory) => ((ReadOnlyMemory)memory).GetArray(); - - public static ArraySegment GetArray(this ReadOnlyMemory memory) - { - if (!MemoryMarshal.TryGetArray(memory, out var result)) - { - ThrowInvalid(); - } - - return result; - void ThrowInvalid() => throw new InvalidOperationException("Buffer backed by array was expected"); - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/CorrelationIdGenerator.cs b/src/Orleans.Core/Networking/Shared/CorrelationIdGenerator.cs deleted file mode 100644 index faff90a3a43..00000000000 --- a/src/Orleans.Core/Networking/Shared/CorrelationIdGenerator.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Threading; - -namespace Orleans.Networking.Shared -{ - internal static class CorrelationIdGenerator - { - // Base32 encoding - in ascii sort order for easy text based sorting - private static readonly char[] s_encode32Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUV".ToCharArray(); - - // Seed the _lastConnectionId for this application instance with - // the number of 100-nanosecond intervals that have elapsed since 12:00:00 midnight, January 1, 0001 - // for a roughly increasing _lastId over restarts - private static long _lastId = DateTime.UtcNow.Ticks; - - public static string GetNextId() => GenerateId(Interlocked.Increment(ref _lastId)); - - private static string GenerateId(long id) - { - return string.Create(13, id, (buffer, value) => - { - char[] encode32Chars = s_encode32Chars; - - buffer[12] = encode32Chars[value & 31]; - buffer[11] = encode32Chars[(value >> 5) & 31]; - buffer[10] = encode32Chars[(value >> 10) & 31]; - buffer[9] = encode32Chars[(value >> 15) & 31]; - buffer[8] = encode32Chars[(value >> 20) & 31]; - buffer[7] = encode32Chars[(value >> 25) & 31]; - buffer[6] = encode32Chars[(value >> 30) & 31]; - buffer[5] = encode32Chars[(value >> 35) & 31]; - buffer[4] = encode32Chars[(value >> 40) & 31]; - buffer[3] = encode32Chars[(value >> 45) & 31]; - buffer[2] = encode32Chars[(value >> 50) & 31]; - buffer[1] = encode32Chars[(value >> 55) & 31]; - buffer[0] = encode32Chars[(value >> 60) & 31]; - }); - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/DuplexPipe.cs b/src/Orleans.Core/Networking/Shared/DuplexPipe.cs deleted file mode 100644 index 72a076e35f2..00000000000 --- a/src/Orleans.Core/Networking/Shared/DuplexPipe.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.IO.Pipelines; - -namespace Orleans.Networking.Shared -{ - internal class DuplexPipe : IDuplexPipe - { - public DuplexPipe(PipeReader reader, PipeWriter writer) - { - Input = reader; - Output = writer; - } - - public PipeReader Input { get; } - - public PipeWriter Output { get; } - - public static DuplexPipePair CreateConnectionPair(PipeOptions inputOptions, PipeOptions outputOptions) - { - var input = new Pipe(inputOptions); - var output = new Pipe(outputOptions); - - var transportToApplication = new DuplexPipe(output.Reader, input.Writer); - var applicationToTransport = new DuplexPipe(input.Reader, output.Writer); - - return new DuplexPipePair(applicationToTransport, transportToApplication); - } - - // This class exists to work around issues with value tuple on .NET Framework - public readonly struct DuplexPipePair - { - public IDuplexPipe Transport { get; } - public IDuplexPipe Application { get; } - - public DuplexPipePair(IDuplexPipe transport, IDuplexPipe application) - { - Transport = transport; - Application = application; - } - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/IOQueue.cs b/src/Orleans.Core/Networking/Shared/IOQueue.cs deleted file mode 100644 index 56853acfef6..00000000000 --- a/src/Orleans.Core/Networking/Shared/IOQueue.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.IO.Pipelines; -using System.Threading; - -namespace Orleans.Networking.Shared -{ - internal sealed class IOQueue : PipeScheduler, IThreadPoolWorkItem - { - private readonly ConcurrentQueue<(Action Callback, object? State)> _workItems = new(); - private int _doingWork; - - public override void Schedule(Action action, object? state) - { - _workItems.Enqueue((action, state)); - - // Set working if it wasn't (via atomic Interlocked). - if (Interlocked.CompareExchange(ref _doingWork, 1, 0) == 0) - { - // Wasn't working, schedule. - _ = System.Threading.ThreadPool.UnsafeQueueUserWorkItem(this, preferLocal: false); - } - } - - public void Execute() - { - while (true) - { - while (_workItems.TryDequeue(out var item)) - { - item.Callback(item.State); - } - - // All work done. - - // Set _doingWork (0 == false) prior to checking IsEmpty to catch any missed work in interim. - // This doesn't need to be volatile due to the following barrier (i.e. it is volatile). - _doingWork = 0; - - // Ensure _doingWork is written before IsEmpty is read. - // As they are two different memory locations, we insert a barrier to guarantee ordering. - Thread.MemoryBarrier(); - - // Check if there is work to do - if (_workItems.IsEmpty) - { - // Nothing to do, exit. - break; - } - - // Is work, can we set it as active again (via atomic Interlocked), prior to scheduling? - if (Interlocked.Exchange(ref _doingWork, 1) == 1) - { - // Execute has been rescheduled already, exit. - break; - } - - // Is work, wasn't already scheduled so continue loop. - } - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/ISocketsTrace.cs b/src/Orleans.Core/Networking/Shared/ISocketsTrace.cs deleted file mode 100644 index 740f9aae45e..00000000000 --- a/src/Orleans.Core/Networking/Shared/ISocketsTrace.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Microsoft.Extensions.Logging; - -namespace Orleans.Networking.Shared -{ - internal interface ISocketsTrace : ILogger - { - void ConnectionReadFin(string connectionId); - - void ConnectionWriteFin(string connectionId, string reason); - - void ConnectionError(string connectionId, Exception ex); - - void ConnectionReset(string connectionId); - - void ConnectionPause(string connectionId); - - void ConnectionResume(string connectionId); - } -} diff --git a/src/Orleans.Core/Networking/Shared/KestrelMemoryPool.cs b/src/Orleans.Core/Networking/Shared/KestrelMemoryPool.cs deleted file mode 100644 index c045a1ee0d0..00000000000 --- a/src/Orleans.Core/Networking/Shared/KestrelMemoryPool.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Buffers; - -namespace Orleans.Networking.Shared -{ - internal static class KestrelMemoryPool - { - public static MemoryPool Create() - { - return CreateSlabMemoryPool(); - } - - public static MemoryPool CreateSlabMemoryPool() - { - return new SlabMemoryPool(); - } - - public static readonly int MinimumSegmentSize = 4096; - } -} diff --git a/src/Orleans.Core/Networking/Shared/MemoryPoolBlock.cs b/src/Orleans.Core/Networking/Shared/MemoryPoolBlock.cs deleted file mode 100644 index 332c8007f79..00000000000 --- a/src/Orleans.Core/Networking/Shared/MemoryPoolBlock.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Buffers; -using System.Runtime.InteropServices; - -namespace Orleans.Networking.Shared -{ - /// - /// Block tracking object used by the byte buffer memory pool. A slab is a large allocation which is divided into smaller blocks. The - /// individual blocks are then treated as independent array segments. - /// - internal sealed class MemoryPoolBlock : IMemoryOwner - { - private readonly int _offset; - private readonly int _length; - - /// - /// This object cannot be instantiated outside of the static Create method - /// - internal MemoryPoolBlock(SlabMemoryPool pool, MemoryPoolSlab slab, int offset, int length) - { - _offset = offset; - _length = length; - - Pool = pool; - Slab = slab; - - Memory = MemoryMarshal.CreateFromPinnedArray(slab.Array!, _offset, _length); - } - - /// - /// Back-reference to the memory pool which this block was allocated from. It may only be returned to this pool. - /// - public SlabMemoryPool Pool { get; } - - /// - /// Back-reference to the slab from which this block was taken, or null if it is one-time-use memory. - /// - public MemoryPoolSlab Slab { get; } - - public Memory Memory { get; } - - ~MemoryPoolBlock() - { - Pool.RefreshBlock(Slab, _offset, _length); - } - - public void Dispose() - { - Pool.Return(this); - } - - public void Lease() - { - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/MemoryPoolSlab.cs b/src/Orleans.Core/Networking/Shared/MemoryPoolSlab.cs deleted file mode 100644 index 22ef7ac30eb..00000000000 --- a/src/Orleans.Core/Networking/Shared/MemoryPoolSlab.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Runtime.InteropServices; - -namespace Orleans.Networking.Shared -{ - /// - /// Slab tracking object used by the byte buffer memory pool. A slab is a large allocation which is divided into smaller blocks. The - /// individual blocks are then treated as independent array segments. - /// - internal class MemoryPoolSlab : IDisposable - { - /// - /// This handle pins the managed array in memory until the slab is disposed. This prevents it from being - /// relocated and enables any subsections of the array to be used as native memory pointers to P/Invoked API calls. - /// - private GCHandle _gcHandle; - private bool _isDisposed; - - public MemoryPoolSlab(byte[] data) - { - Array = data; - _gcHandle = GCHandle.Alloc(data, GCHandleType.Pinned); - NativePointer = _gcHandle.AddrOfPinnedObject(); - } - - /// - /// True as long as the blocks from this slab are to be considered returnable to the pool. In order to shrink the - /// memory pool size an entire slab must be removed. That is done by (1) setting IsActive to false and removing the - /// slab from the pool's _slabs collection, (2) as each block currently in use is Return()ed to the pool it will - /// be allowed to be garbage collected rather than re-pooled, and (3) when all block tracking objects are garbage - /// collected and the slab is no longer references the slab will be garbage collected and the memory unpinned will - /// be unpinned by the slab's Dispose. - /// - public bool IsActive => !_isDisposed; - - public IntPtr NativePointer { get; private set; } - - public byte[]? Array { get; private set; } - - public static MemoryPoolSlab Create(int length) - { - // allocate and pin requested memory length - var array = new byte[length]; - - // allocate and return slab tracking object - return new MemoryPoolSlab(array); - } - - protected void Dispose(bool disposing) - { - if (_isDisposed) - { - return; - } - - _isDisposed = true; - - Array = null; - NativePointer = IntPtr.Zero; - - if (_gcHandle.IsAllocated) - { - _gcHandle.Free(); - } - } - - ~MemoryPoolSlab() - { - Dispose(false); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/SharedMemoryPool.cs b/src/Orleans.Core/Networking/Shared/SharedMemoryPool.cs deleted file mode 100644 index 5a4551d21ed..00000000000 --- a/src/Orleans.Core/Networking/Shared/SharedMemoryPool.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Buffers; - -namespace Orleans.Networking.Shared -{ - internal sealed class SharedMemoryPool - { - public MemoryPool Pool { get; } = KestrelMemoryPool.Create(); - } -} diff --git a/src/Orleans.Core/Networking/Shared/SlabMemoryPool.cs b/src/Orleans.Core/Networking/Shared/SlabMemoryPool.cs deleted file mode 100644 index 3e61532bb4d..00000000000 --- a/src/Orleans.Core/Networking/Shared/SlabMemoryPool.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System; -using System.Buffers; -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Threading; - -namespace Orleans.Networking.Shared -{ - /// - /// Used to allocate and distribute re-usable blocks of memory. - /// - internal sealed class SlabMemoryPool : MemoryPool - { - /// - /// The size of a block. 4096 is chosen because most operating systems use 4k pages. - /// - private const int _blockSize = 4096; - - /// - /// Allocating 32 contiguous blocks per slab makes the slab size 128k. This is larger than the 85k size which will place the memory - /// in the large object heap. This means the GC will not try to relocate this array, so the fact it remains pinned does not negatively - /// affect memory management's compactification. - /// - private const int _blockCount = 32; - - /// - /// Max allocation block size for pooled blocks, - /// larger values can be leased but they will be disposed after use rather than returned to the pool. - /// - public override int MaxBufferSize { get; } = _blockSize; - - /// - /// The size of a block. 4096 is chosen because most operating systems use 4k pages. - /// - public static int BlockSize => _blockSize; - - /// - /// 4096 * 32 gives you a slabLength of 128k contiguous bytes allocated per slab - /// - private static readonly int _slabLength = _blockSize * _blockCount; - - /// - /// Thread-safe collection of blocks which are currently in the pool. A slab will pre-allocate all of the block tracking objects - /// and add them to this collection. When memory is requested it is taken from here first, and when it is returned it is re-added. - /// - private readonly ConcurrentQueue _blocks = new ConcurrentQueue(); - - /// - /// Thread-safe collection of slabs which have been allocated by this pool. As long as a slab is in this collection and slab.IsActive, - /// the blocks will be added to _blocks when returned. - /// - private readonly ConcurrentStack _slabs = new ConcurrentStack(); - - /// - /// This is part of implementing the IDisposable pattern. - /// - private bool _isDisposed; // To detect redundant calls - - private int _totalAllocatedBlocks; - - private readonly object _disposeSync = new object(); - - /// - /// This default value passed in to Rent to use the default value for the pool. - /// - private const int AnySize = -1; - - public override IMemoryOwner Rent(int size = AnySize) - { - if (size > _blockSize) - { - ThrowArgumentOutOfRangeException_BufferRequestTooLarge(size, _blockSize); - } - - var block = Lease(); - return block; - } - - /// - /// Called to take a block from the pool. - /// - /// The block that is reserved for the called. It must be passed to Return when it is no longer being used. - private MemoryPoolBlock Lease() - { - if (_isDisposed) - { - ThrowObjectDisposedException(); - } - - if (_blocks.TryDequeue(out MemoryPoolBlock? block)) - { - // block successfully taken from the stack - return it - - block.Lease(); - return block; - } - // no blocks available - grow the pool - block = AllocateSlab(); - block.Lease(); - return block; - } - - /// - /// Internal method called when a block is requested and the pool is empty. It allocates one additional slab, creates all of the - /// block tracking objects, and adds them all to the pool. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Reliability", - "CA2000:Dispose objects before losing scope", - Justification = "The pool owns the slab and blocks after adding them to its collections and disposes the slabs with the pool.")] - private MemoryPoolBlock AllocateSlab() - { - var slab = MemoryPoolSlab.Create(_slabLength); - _slabs.Push(slab); - - var basePtr = slab.NativePointer; - // Page align the blocks - var offset = (int)((((ulong)basePtr + (uint)_blockSize - 1) & ~((uint)_blockSize - 1)) - (ulong)basePtr); - // Ensure page aligned - Debug.Assert(((ulong)basePtr + (uint)offset) % _blockSize == 0); - - var blockCount = (_slabLength - offset) / _blockSize; - Interlocked.Add(ref _totalAllocatedBlocks, blockCount); - - MemoryPoolBlock? block = null; - - for (int i = 0; i < blockCount; i++) - { - block = new MemoryPoolBlock(this, slab, offset, _blockSize); - - if (i != blockCount - 1) // last block - { -#if BLOCK_LEASE_TRACKING - block.IsLeased = true; -#endif - Return(block); - } - - offset += _blockSize; - } - - return block!; - } - - /// - /// Called to return a block to the pool. Once Return has been called the memory no longer belongs to the caller, and - /// Very Bad Things will happen if the memory is read of modified subsequently. If a caller fails to call Return and the - /// block tracking object is garbage collected, the block tracking object's finalizer will automatically re-create and return - /// a new tracking object into the pool. This will only happen if there is a bug in the server, however it is necessary to avoid - /// leaving "dead zones" in the slab due to lost block tracking objects. - /// - /// The block to return. It must have been acquired by calling Lease on the same memory pool instance. - internal void Return(MemoryPoolBlock block) - { -#if BLOCK_LEASE_TRACKING - Debug.Assert(block.Pool == this, "Returned block was not leased from this pool"); - Debug.Assert(block.IsLeased, $"Block being returned to pool twice: {block.Leaser}{Environment.NewLine}"); - block.IsLeased = false; -#endif - - if (!_isDisposed) - { - _blocks.Enqueue(block); - } - else - { - GC.SuppressFinalize(block); - } - } - - // This method can ONLY be called from the finalizer of MemoryPoolBlock - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Reliability", - "CA2000:Dispose objects before losing scope", - Justification = "Return transfers ownership of the replacement block back to the pool.")] - internal void RefreshBlock(MemoryPoolSlab slab, int offset, int length) - { - lock (_disposeSync) - { - if (!_isDisposed && slab != null && slab.IsActive) - { - // Need to make a new object because this one is being finalized - // Note, this must be called within the _disposeSync lock because the block - // could be disposed at the same time as the finalizer. - Return(new MemoryPoolBlock(this, slab, offset, length)); - } - } - } - - protected override void Dispose(bool disposing) - { - if (_isDisposed) - { - return; - } - - lock (_disposeSync) - { - _isDisposed = true; - - if (disposing) - { - while (_slabs.TryPop(out MemoryPoolSlab? slab)) - { - // dispose managed state (managed objects). - slab.Dispose(); - } - } - - // Discard blocks in pool - while (_blocks.TryDequeue(out MemoryPoolBlock? block)) - { - GC.SuppressFinalize(block); - } - } - } - - private static void ThrowArgumentOutOfRangeException_BufferRequestTooLarge(int size, int maxSize) - { - throw new ArgumentOutOfRangeException(nameof(size), size, $"Cannot allocate more than {maxSize} bytes in a single buffer"); - } - - private static void ThrowObjectDisposedException() => throw new ObjectDisposedException("MemoryPool"); - } -} diff --git a/src/Orleans.Core/Networking/Shared/SocketAwaitableEventArgs.cs b/src/Orleans.Core/Networking/Shared/SocketAwaitableEventArgs.cs deleted file mode 100644 index 715a5bc2a3e..00000000000 --- a/src/Orleans.Core/Networking/Shared/SocketAwaitableEventArgs.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO.Pipelines; -using System.Net.Sockets; -using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; - -namespace Orleans.Networking.Shared -{ - internal class SocketAwaitableEventArgs : SocketAsyncEventArgs, ICriticalNotifyCompletion - { - private static readonly Action _callbackCompleted = () => { }; - - private readonly PipeScheduler _ioScheduler; - - private Action? _callback; - - public SocketAwaitableEventArgs(PipeScheduler ioScheduler) - { - _ioScheduler = ioScheduler; - } - - public SocketAwaitableEventArgs GetAwaiter() => this; - public bool IsCompleted => ReferenceEquals(_callback, _callbackCompleted); - - public int GetResult() - { - Debug.Assert(ReferenceEquals(_callback, _callbackCompleted)); - - _callback = null; - - if (SocketError != SocketError.Success) - { - ThrowSocketException(SocketError); - } - - return BytesTransferred; - - void ThrowSocketException(SocketError e) - { - throw new SocketException((int)e); - } - } - - public void OnCompleted(Action continuation) - { - if (ReferenceEquals(_callback, _callbackCompleted) || - ReferenceEquals(Interlocked.CompareExchange(ref _callback, continuation, null), _callbackCompleted)) - { - Task.Run(continuation); - } - } - - public void UnsafeOnCompleted(Action continuation) - { - OnCompleted(continuation); - } - - public void Complete() - { - OnCompleted(this); - } - - protected override void OnCompleted(SocketAsyncEventArgs _) - { - var continuation = Interlocked.Exchange(ref _callback, _callbackCompleted); - - if (continuation != null) - { - _ioScheduler.Schedule(state => ((Action)state!)(), continuation); - } - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/SocketConnection.cs b/src/Orleans.Core/Networking/Shared/SocketConnection.cs deleted file mode 100644 index ec752b93f80..00000000000 --- a/src/Orleans.Core/Networking/Shared/SocketConnection.cs +++ /dev/null @@ -1,402 +0,0 @@ -using System; -using System.Buffers; -using System.Diagnostics; -using System.IO.Pipelines; -using System.Net.Sockets; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.Logging; - -namespace Orleans.Networking.Shared -{ - internal sealed partial class SocketConnection : TransportConnection - { - private static readonly int MinAllocBufferSize = SlabMemoryPool.BlockSize / 2; - private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - private static readonly bool IsMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); - - private readonly Socket _socket; - private readonly ISocketsTrace _trace; - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Usage", - "CA2213:Disposable fields should be disposed", - Justification = "StartAsync joins both I/O loops and unconditionally disposes the receive helper in its finally block.")] - private readonly SocketReceiver _receiver; - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Usage", - "CA2213:Disposable fields should be disposed", - Justification = "StartAsync joins both I/O loops and unconditionally disposes the send helper in its finally block.")] - private readonly SocketSender _sender; - private readonly CancellationTokenSource _connectionClosedTokenSource = new CancellationTokenSource(); - -#if NET9_0_OR_GREATER - private readonly Lock _shutdownLock = new(); -#else - private readonly object _shutdownLock = new(); -#endif - private volatile bool _socketDisposed; - private volatile Exception? _shutdownReason; - private Task? _processingTask; - private readonly TaskCompletionSource _waitForConnectionClosedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - private bool _connectionClosed; - - internal SocketConnection(Socket socket, - MemoryPool memoryPool, - PipeScheduler scheduler, - ISocketsTrace trace, - long? maxReadBufferSize = null, - long? maxWriteBufferSize = null) - { - Debug.Assert(socket != null); - Debug.Assert(memoryPool != null); - Debug.Assert(trace != null); - - _socket = socket; - MemoryPool = memoryPool; - _trace = trace; - - LocalEndPoint = _socket.LocalEndPoint; - RemoteEndPoint = _socket.RemoteEndPoint; - - ConnectionClosed = _connectionClosedTokenSource.Token; - - // On *nix platforms, Sockets already dispatches to the ThreadPool. - // Yes, the IOQueues are still used for the PipeSchedulers. This is intentional. - // https://github.com/aspnet/KestrelHttpServer/issues/2573 - var awaiterScheduler = IsWindows ? scheduler : PipeScheduler.Inline; - - _receiver = new SocketReceiver(_socket, awaiterScheduler); - _sender = new SocketSender(_socket, awaiterScheduler); - - maxReadBufferSize = maxReadBufferSize ?? 0; - maxWriteBufferSize = maxWriteBufferSize ?? 0; - - var inputOptions = new PipeOptions(MemoryPool, PipeScheduler.ThreadPool, scheduler, maxReadBufferSize.Value, maxReadBufferSize.Value / 2, useSynchronizationContext: false); - var outputOptions = new PipeOptions(MemoryPool, scheduler, PipeScheduler.ThreadPool, maxWriteBufferSize.Value, maxWriteBufferSize.Value / 2, useSynchronizationContext: false); - - var pair = DuplexPipe.CreateConnectionPair(inputOptions, outputOptions); - - // Set the transport and connection id - Transport = pair.Transport; - Application = pair.Application; - } - - public PipeWriter Input => Application.Output; - - public PipeReader Output => Application.Input; - - public override MemoryPool MemoryPool { get; } - - public void Start() - { - _processingTask = StartAsync(); - } - - private async Task StartAsync() - { - var receiveTask = DoReceive(); - var sendTask = DoSend(); - try - { - await Task.WhenAll(receiveTask, sendTask); - } - catch (Exception ex) - { - LogErrorUnexpectedExceptionInStartAsync(_trace, ex); - } - finally - { - _receiver.Dispose(); - _sender.Dispose(); - } - } - - public override void Abort(ConnectionAbortedException abortReason) - { - // Try to gracefully close the socket to match libuv behavior. - Shutdown(abortReason); - - // Cancel ProcessSends loop after calling shutdown to ensure the correct _shutdownReason gets set. - Output.CancelPendingRead(); - } - - // Only called after connection middleware is complete which means the ConnectionClosed token has fired. - public override async ValueTask DisposeAsync() - { - await Transport.Input.CompleteAsync(); - await Transport.Output.CompleteAsync(); - - if (_processingTask != null) - { - await _processingTask; - } - - _connectionClosedTokenSource.Dispose(); - } - - private async Task DoReceive() - { - Exception? error = null; - - try - { - await ProcessReceives(); - } - catch (SocketException ex) when (IsConnectionResetError(ex.SocketErrorCode)) - { - // This could be ignored if _shutdownReason is already set. - error = new ConnectionResetException(ex.Message, ex); - - // There's still a small chance that both DoReceive() and DoSend() can log the same connection reset. - // Both logs will have the same ConnectionId. I don't think it's worthwhile to lock just to avoid this. - if (!_socketDisposed) - { - _trace.ConnectionReset(ConnectionId); - } - } - catch (Exception ex) - when ((ex is SocketException socketEx && IsConnectionAbortError(socketEx.SocketErrorCode)) || - ex is ObjectDisposedException) - { - // This exception should always be ignored because _shutdownReason should be set. - error = ex; - - if (!_socketDisposed) - { - // This is unexpected if the socket hasn't been disposed yet. - _trace.ConnectionError(ConnectionId, error); - } - } - catch (Exception ex) - { - // This is unexpected. - error = ex; - _trace.ConnectionError(ConnectionId, error); - } - finally - { - // If Shutdown() has already bee called, assume that was the reason ProcessReceives() exited. - await Input.CompleteAsync(_shutdownReason ?? error); - - FireConnectionClosed(); - - await _waitForConnectionClosedTcs.Task; - } - } - - private async Task ProcessReceives() - { - // Resolve `input` PipeWriter via the IDuplexPipe interface prior to loop start for performance. - var input = Input; - while (true) - { - // Wait for data before allocating a buffer. - await _receiver.WaitForDataAsync(); - - // Ensure we have some reasonable amount of buffer space - var buffer = input.GetMemory(MinAllocBufferSize); - - var bytesReceived = await _receiver.ReceiveAsync(buffer); - - if (bytesReceived == 0) - { - // FIN - _trace.ConnectionReadFin(ConnectionId); - break; - } - - input.Advance(bytesReceived); - - var flushTask = input.FlushAsync(); - - var paused = !flushTask.IsCompleted; - - if (paused) - { - _trace.ConnectionPause(ConnectionId); - } - - var result = await flushTask; - - if (paused) - { - _trace.ConnectionResume(ConnectionId); - } - - if (result.IsCompleted || result.IsCanceled) - { - // Pipe consumer is shut down, do we stop writing - break; - } - } - } - - private async Task DoSend() - { - Exception? shutdownReason = null; - Exception? unexpectedError = null; - - try - { - await ProcessSends(); - } - catch (SocketException ex) when (IsConnectionResetError(ex.SocketErrorCode)) - { - shutdownReason = new ConnectionResetException(ex.Message, ex); - _trace.ConnectionReset(ConnectionId); - } - catch (Exception ex) - when ((ex is SocketException socketEx && IsConnectionAbortError(socketEx.SocketErrorCode)) || - ex is ObjectDisposedException) - { - // This should always be ignored since Shutdown() must have already been called by Abort(). - shutdownReason = ex; - } - catch (Exception ex) - { - shutdownReason = ex; - unexpectedError = ex; - _trace.ConnectionError(ConnectionId, unexpectedError); - } - finally - { - Shutdown(shutdownReason); - - // Complete the output after disposing the socket - await Output.CompleteAsync(unexpectedError); - - // Cancel any pending flushes so that the input loop is un-paused - Input.CancelPendingFlush(); - } - } - - private async Task ProcessSends() - { - // Resolve `output` PipeReader via the IDuplexPipe interface prior to loop start for performance. - var output = Output; - while (true) - { - var result = await output.ReadAsync(); - - if (result.IsCanceled) - { - break; - } - - var buffer = result.Buffer; - - var end = buffer.End; - var isCompleted = result.IsCompleted; - if (!buffer.IsEmpty) - { - await _sender.SendAsync(buffer); - } - - output.AdvanceTo(end); - - if (isCompleted) - { - break; - } - } - } - - private void FireConnectionClosed() - { - // Guard against scheduling this multiple times - if (_connectionClosed) - { - return; - } - - _connectionClosed = true; - - ThreadPool.UnsafeQueueUserWorkItem(state => - { - ((SocketConnection)state!).CancelConnectionClosedToken(); - - ((SocketConnection)state!)._waitForConnectionClosedTcs.TrySetResult(null); - }, - this); - } - - private void Shutdown(Exception? shutdownReason) - { - lock (_shutdownLock) - { - if (_socketDisposed) - { - return; - } - - // Make sure to close the connection only after the _aborted flag is set. - // Without this, the RequestsCanBeAbortedMidRead test will sometimes fail when - // a BadHttpRequestException is thrown instead of a TaskCanceledException. - _socketDisposed = true; - - // shutdownReason should only be null if the output was completed gracefully, so no one should ever - // ever observe the nondescript ConnectionAbortedException except for connection middleware attempting - // to half close the connection which is currently unsupported. - _shutdownReason = shutdownReason ?? new ConnectionAbortedException("The Socket transport's send loop completed gracefully."); - - _trace.ConnectionWriteFin(ConnectionId, _shutdownReason.Message); - - try - { - // Try to gracefully close the socket even for aborts to match libuv behavior. - _socket.Shutdown(SocketShutdown.Both); - } - catch - { - // Ignore any errors from Socket.Shutdown() since we're tearing down the connection anyway. - } - - _socket.Dispose(); - } - } - - private void CancelConnectionClosedToken() - { - try - { - _connectionClosedTokenSource.Cancel(); - } - catch (Exception ex) - { - LogErrorUnexpectedExceptionInCancelConnectionClosedToken(_trace, ex); - } - } - - private static bool IsConnectionResetError(SocketError errorCode) - { - // A connection reset can be reported as SocketError.ConnectionAborted on Windows. - // ProtocolType can be removed once https://github.com/dotnet/corefx/issues/31927 is fixed. - return errorCode == SocketError.ConnectionReset || - errorCode == SocketError.Shutdown || - (errorCode == SocketError.ConnectionAborted && IsWindows) || - (errorCode == SocketError.ProtocolType && IsMacOS); - } - - private static bool IsConnectionAbortError(SocketError errorCode) - { - // Calling Dispose after ReceiveAsync can cause an "InvalidArgument" error on *nix. - return errorCode == SocketError.OperationAborted || - errorCode == SocketError.Interrupted || - (errorCode == SocketError.InvalidArgument && !IsWindows); - } - - [LoggerMessage( - Level = LogLevel.Error, - Message = "Unexpected exception in SocketConnection.StartAsync." - )] - private static partial void LogErrorUnexpectedExceptionInStartAsync(ILogger logger, Exception exception); - - [LoggerMessage( - Level = LogLevel.Error, - Message = "Unexpected exception in SocketConnection.CancelConnectionClosedToken." - )] - private static partial void LogErrorUnexpectedExceptionInCancelConnectionClosedToken(ILogger logger, Exception exception); - } -} diff --git a/src/Orleans.Core/Networking/Shared/SocketConnectionFactory.cs b/src/Orleans.Core/Networking/Shared/SocketConnectionFactory.cs deleted file mode 100644 index 7d42d8816ab..00000000000 --- a/src/Orleans.Core/Networking/Shared/SocketConnectionFactory.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System; -using System.Buffers; -using System.Net; -using System.Net.Sockets; -using System.Runtime.Serialization; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Orleans.Runtime; - -namespace Orleans.Networking.Shared -{ - internal class SocketConnectionFactory : IConnectionFactory - { - private readonly SocketsTrace trace; - private readonly SocketSchedulers schedulers; - private readonly MemoryPool memoryPool; - private readonly SocketConnectionOptions _options; - - public SocketConnectionFactory(ILoggerFactory loggerFactory, SocketSchedulers schedulers, SharedMemoryPool memoryPool, IOptions options) - { - var logger = loggerFactory.CreateLogger("Orleans.Sockets"); - this.trace = new SocketsTrace(logger); - this.schedulers = schedulers; - this.memoryPool = memoryPool.Pool; - _options = options.Value; - } - - public async ValueTask ConnectAsync(EndPoint endpoint, CancellationToken cancellationToken) - { - Socket? socket = new Socket(endpoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp) - { - LingerState = new LingerOption(true, 0), - NoDelay = _options.NoDelay, - }; - - try - { - if (_options.KeepAlive) - { - socket.EnableKeepAlive( - timeSeconds: _options.KeepAliveTimeSeconds, - intervalSeconds: _options.KeepAliveIntervalSeconds, - retryCount: _options.KeepAliveRetryCount); - } - - socket.EnableFastPath(); - using var completion = new SingleUseSocketAsyncEventArgs - { - RemoteEndPoint = endpoint - }; - - if (socket.ConnectAsync(completion)) - { - using (cancellationToken.Register(s => Socket.CancelConnectAsync((SingleUseSocketAsyncEventArgs)s!), completion)) - { - await completion.Task; - } - } - - if (completion.SocketError != SocketError.Success) - { - if (completion.SocketError == SocketError.OperationAborted) - cancellationToken.ThrowIfCancellationRequested(); - throw new SocketConnectionException($"Unable to connect to {endpoint}. Error: {completion.SocketError}"); - } - - var scheduler = this.schedulers.GetScheduler(); - var connection = new SocketConnection(socket, this.memoryPool, scheduler, this.trace); - connection.Start(); - socket = null; - return connection; - } - finally - { - socket?.Dispose(); - } - } - - private sealed class SingleUseSocketAsyncEventArgs : SocketAsyncEventArgs - { - private readonly TaskCompletionSource completion = new(); - - public Task Task => completion.Task; - - protected override void OnCompleted(SocketAsyncEventArgs _) => this.completion.TrySetResult(null); - } - } - - /// - /// Represents an error which occurred while establishing a socket connection. - /// - [Serializable] - [GenerateSerializer] - public sealed class SocketConnectionException : OrleansException - { - /// - /// Initializes a new instance of the class. - /// - /// The message that describes the error. - public SocketConnectionException(string message) : base(message) { } - - /// - /// Initializes a new instance of the class. - /// - /// The message that describes the error. - /// The exception that caused the current exception. - public SocketConnectionException(string message, Exception innerException) : base(message, innerException) { } - - /// - /// Initializes a new instance of the class from serialized data. - /// - /// The serialized object data. - /// Context about the serialization source or destination. - [Obsolete] - public SocketConnectionException(SerializationInfo info, StreamingContext context) - : base(info, context) - { - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/SocketConnectionListener.cs b/src/Orleans.Core/Networking/Shared/SocketConnectionListener.cs deleted file mode 100644 index 7766514da31..00000000000 --- a/src/Orleans.Core/Networking/Shared/SocketConnectionListener.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System; -using System.Buffers; -using System.Diagnostics; -using System.Net; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; - -namespace Orleans.Networking.Shared -{ - internal sealed class SocketConnectionListener : IConnectionListener - { - private readonly MemoryPool _memoryPool; - private readonly SocketSchedulers _schedulers; - private readonly ISocketsTrace _trace; - private Socket? _listenSocket; - private readonly SocketConnectionOptions _options; - - public EndPoint EndPoint { get; private set; } - - internal SocketConnectionListener( - EndPoint endpoint, - SocketConnectionOptions options, - ISocketsTrace trace, - SocketSchedulers schedulers) - { - Debug.Assert(endpoint != null); - Debug.Assert(endpoint is IPEndPoint); - Debug.Assert(trace != null); - - EndPoint = endpoint; - _trace = trace; - _schedulers = schedulers; - _options = options; - _memoryPool = options.MemoryPoolFactory(); - } - - internal void Bind() - { - if (_listenSocket != null) - { - throw new InvalidOperationException("Transport already bound"); - } - - Socket? listenSocket = new Socket(EndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp) - { - LingerState = new LingerOption(true, 0), - NoDelay = true - }; - - try - { - listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - if (_options.KeepAlive) - { - listenSocket.EnableKeepAlive( - timeSeconds: _options.KeepAliveTimeSeconds, - intervalSeconds: _options.KeepAliveIntervalSeconds, - retryCount: _options.KeepAliveRetryCount); - } - - listenSocket.EnableFastPath(); - - // Kestrel expects IPv6Any to bind to both IPv6 and IPv4 - if (EndPoint is IPEndPoint ip && Equals(ip.Address, IPAddress.IPv6Any)) - { - listenSocket.DualMode = true; - } - - try - { - listenSocket.Bind(EndPoint); - } - catch (SocketException e) when (e.SocketErrorCode == SocketError.AddressAlreadyInUse) - { - throw new AddressInUseException(e.Message, e); - } - - EndPoint = listenSocket.LocalEndPoint!; - - listenSocket.Listen(512); - - _listenSocket = listenSocket; - listenSocket = null; - } - finally - { - listenSocket?.Dispose(); - } - } - - public async ValueTask AcceptAsync(CancellationToken cancellationToken = default) - { - while (true) - { - try - { - Socket? acceptSocket = await _listenSocket!.AcceptAsync(cancellationToken); - try - { - acceptSocket.NoDelay = _options.NoDelay; - if (_options.KeepAlive) - { - acceptSocket.EnableKeepAlive( - timeSeconds: _options.KeepAliveTimeSeconds, - intervalSeconds: _options.KeepAliveIntervalSeconds, - retryCount: _options.KeepAliveRetryCount); - } - - var connection = new SocketConnection(acceptSocket, _memoryPool, _schedulers.GetScheduler(), _trace); - - connection.Start(); - acceptSocket = null; - - return connection; - } - finally - { - acceptSocket?.Dispose(); - } - } - catch (ObjectDisposedException) - { - // A call was made to UnbindAsync/DisposeAsync just return null which signals we're done - return null; - } - catch (SocketException e) when (e.SocketErrorCode == SocketError.OperationAborted) - { - // A call was made to UnbindAsync/DisposeAsync just return null which signals we're done - return null; - } - catch (SocketException) - { - // The connection got reset while it was in the backlog, so we try again. - _trace.ConnectionReset(connectionId: "(null)"); - } - } - } - - public ValueTask UnbindAsync(CancellationToken cancellationToken) - { - _listenSocket?.Dispose(); - - return default; - } - - public ValueTask DisposeAsync() - { - _listenSocket?.Dispose(); - // Dispose the memory pool - _memoryPool.Dispose(); - return default; - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/SocketConnectionListenerFactory.cs b/src/Orleans.Core/Networking/Shared/SocketConnectionListenerFactory.cs deleted file mode 100644 index 2484e7e1fd1..00000000000 --- a/src/Orleans.Core/Networking/Shared/SocketConnectionListenerFactory.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using System.Net; -using System.Runtime.ExceptionServices; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Orleans.Networking.Shared -{ - internal sealed class SocketConnectionListenerFactory : IConnectionListenerFactory - { - private readonly SocketConnectionOptions socketConnectionOptions; - private readonly SocketsTrace trace; - private readonly SocketSchedulers schedulers; - - public SocketConnectionListenerFactory( - ILoggerFactory loggerFactory, - IOptions socketConnectionOptions, - SocketSchedulers schedulers) - { - if (loggerFactory == null) - { - throw new ArgumentNullException(nameof(loggerFactory)); - } - - this.socketConnectionOptions = socketConnectionOptions.Value; - var logger = loggerFactory.CreateLogger("Orleans.Sockets"); - this.trace = new SocketsTrace(logger); - this.schedulers = schedulers; - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Reliability", - "CA2000:Dispose objects before losing scope", - Justification = "Successful binding transfers ownership to the caller; failed binding transfers the listener to DisposeAndThrowAsync.")] - public ValueTask BindAsync(EndPoint endpoint, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(endpoint); - if (endpoint is not IPEndPoint ipEndpoint) - { - throw new ArgumentException($"The endpoint must be an {nameof(IPEndPoint)}.", nameof(endpoint)); - } - - var listener = new SocketConnectionListener(ipEndpoint, this.socketConnectionOptions, this.trace, this.schedulers); - try - { - listener.Bind(); - return new(listener); - } - catch (Exception exception) - { - return DisposeAndThrowAsync(listener, exception); - } - } - - private static async ValueTask DisposeAndThrowAsync(SocketConnectionListener listener, Exception exception) - { - try - { - await listener.DisposeAsync(); - } - catch (Exception disposeException) - { - exception.Data["Orleans.SocketConnectionListener.DisposeException"] = disposeException; - } - - ExceptionDispatchInfo.Throw(exception); - return null!; - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/SocketConnectionOptions.cs b/src/Orleans.Core/Networking/Shared/SocketConnectionOptions.cs deleted file mode 100644 index 46c8f8842d4..00000000000 --- a/src/Orleans.Core/Networking/Shared/SocketConnectionOptions.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Buffers; - -namespace Orleans.Networking.Shared -{ - /// - /// Options for configuring socket connections. - /// - public class SocketConnectionOptions - { - /// - /// The number of I/O queues used to process requests. Set to 0 to directly schedule I/O to the ThreadPool. - /// - /// - /// Defaults to rounded down and clamped between 1 and 16. - /// - public int IOQueueCount { get; set; } = Math.Min(Environment.ProcessorCount, 16); - - /// - /// Whether the Nagle algorithm should be enabled or disabled. - /// - public bool NoDelay { get; set; } = true; - - /// - /// Whether TCP KeepAlive is enabled or disabled. - /// - public bool KeepAlive { get; set; } = true; - - /// - /// The number of seconds before the first keep-alive packet is sent on an idle connection. - /// - /// - public int KeepAliveTimeSeconds { get; set; } = 90; - - /// - /// The number of seconds between keep-alive packets when the remote endpoint is not responding. - /// - /// - public int KeepAliveIntervalSeconds { get; set; } = 30; - - /// - /// The number of retry attempts for keep-alive packets before the connection is considered dead. - /// - /// - public int KeepAliveRetryCount { get; set; } = 10; - - /// - /// Gets or sets the memory pool factory. - /// - internal Func> MemoryPoolFactory { get; set; } = () => KestrelMemoryPool.Create(); - } -} diff --git a/src/Orleans.Core/Networking/Shared/SocketExtensions.cs b/src/Orleans.Core/Networking/Shared/SocketExtensions.cs deleted file mode 100644 index 5795e2c119c..00000000000 --- a/src/Orleans.Core/Networking/Shared/SocketExtensions.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Net.Sockets; -using System.Runtime.InteropServices; - -namespace Orleans.Networking.Shared -{ - internal static class SocketExtensions - { - private const int SIO_LOOPBACK_FAST_PATH = -1744830448; - private static readonly byte[] Enabled = BitConverter.GetBytes(1); - - /// - /// Enables TCP Loopback Fast Path on a socket. - /// See https://blogs.technet.microsoft.com/wincat/2012/12/05/fast-tcp-loopback-performance-and-low-latency-with-windows-server-2012-tcp-loopback-fast-path/ - /// for more information. - /// - /// The socket for which FastPath should be enabled. - internal static void EnableFastPath(this Socket socket) - { - try { socket.NoDelay = true; } catch { } - - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return; - } - - try - { - // Win8/Server2012+ only - var osVersion = Environment.OSVersion.Version; - if (osVersion.Major > 6 || osVersion.Major == 6 && osVersion.Minor >= 2) - { - socket.IOControl(SIO_LOOPBACK_FAST_PATH, Enabled, null); - } - } - catch - { - // If the operating system version on this machine did - // not support SIO_LOOPBACK_FAST_PATH (i.e. version - // prior to Windows 8 / Windows Server 2012), handle the exception - } - } - - /// - /// Enables TCP KeepAlive on a socket. - /// - /// The socket. - /// The idle time, in seconds, before the first keep-alive probe is sent. - /// The interval, in seconds, between keep-alive probes. - /// The number of unanswered probes allowed before the connection is terminated. - internal static void EnableKeepAlive(this Socket socket, int timeSeconds = 90, int intervalSeconds = 30, int retryCount = 2) - { - try - { - socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); - socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, timeSeconds); - socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, intervalSeconds); - socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, retryCount); - } - catch - { - } - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/SocketReceiver.cs b/src/Orleans.Core/Networking/Shared/SocketReceiver.cs deleted file mode 100644 index af1e922a62f..00000000000 --- a/src/Orleans.Core/Networking/Shared/SocketReceiver.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.IO.Pipelines; -using System.Net.Sockets; - -namespace Orleans.Networking.Shared -{ - internal sealed class SocketReceiver : SocketSenderReceiverBase - { - public SocketReceiver(Socket socket, PipeScheduler scheduler) : base(socket, scheduler) - { - } - - public SocketAwaitableEventArgs WaitForDataAsync() - { - _awaitableEventArgs.SetBuffer(Memory.Empty); - - if (!_socket.ReceiveAsync(_awaitableEventArgs)) - { - _awaitableEventArgs.Complete(); - } - - return _awaitableEventArgs; - } - - public SocketAwaitableEventArgs ReceiveAsync(Memory buffer) - { - _awaitableEventArgs.SetBuffer(buffer); - - if (!_socket.ReceiveAsync(_awaitableEventArgs)) - { - _awaitableEventArgs.Complete(); - } - - return _awaitableEventArgs; - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/SocketSchedulers.cs b/src/Orleans.Core/Networking/Shared/SocketSchedulers.cs deleted file mode 100644 index 30affdcf12e..00000000000 --- a/src/Orleans.Core/Networking/Shared/SocketSchedulers.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.IO.Pipelines; -using Microsoft.Extensions.Options; - -namespace Orleans.Networking.Shared -{ - internal class SocketSchedulers - { - private static readonly PipeScheduler[] ThreadPoolSchedulerArray = new PipeScheduler[] { PipeScheduler.ThreadPool }; - private readonly int _numSchedulers; - private readonly PipeScheduler[] _schedulers; - private int nextScheduler; - - public SocketSchedulers(IOptions options) - { - var o = options.Value; - if (o.IOQueueCount > 0) - { - _numSchedulers = o.IOQueueCount; - _schedulers = new IOQueue[_numSchedulers]; - - for (var i = 0; i < _numSchedulers; i++) - { - _schedulers[i] = new IOQueue(); - } - } - else - { - _numSchedulers = ThreadPoolSchedulerArray.Length; - _schedulers = ThreadPoolSchedulerArray; - } - } - - public PipeScheduler GetScheduler() => _schedulers[++nextScheduler % _numSchedulers]; - } -} diff --git a/src/Orleans.Core/Networking/Shared/SocketSender.cs b/src/Orleans.Core/Networking/Shared/SocketSender.cs deleted file mode 100644 index d0a11e5da20..00000000000 --- a/src/Orleans.Core/Networking/Shared/SocketSender.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Buffers; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO.Pipelines; -using System.Net.Sockets; -using System.Runtime.InteropServices; - -namespace Orleans.Networking.Shared -{ - internal sealed class SocketSender : SocketSenderReceiverBase - { - private List>? _bufferList; - - public SocketSender(Socket socket, PipeScheduler scheduler) : base(socket, scheduler) - { - } - - public SocketAwaitableEventArgs SendAsync(in ReadOnlySequence buffers) - { - if (buffers.IsSingleSegment) - { - return SendAsync(buffers.First); - } - - if (!_awaitableEventArgs.Equals(Memory.Empty)) - { - _awaitableEventArgs.SetBuffer(null, 0, 0); - } - - _awaitableEventArgs.BufferList = GetBufferList(buffers); - - if (!_socket.SendAsync(_awaitableEventArgs)) - { - _awaitableEventArgs.Complete(); - } - - return _awaitableEventArgs; - } - - private SocketAwaitableEventArgs SendAsync(ReadOnlyMemory memory) - { - // The BufferList getter is much less expensive then the setter. - if (_awaitableEventArgs.BufferList != null) - { - _awaitableEventArgs.BufferList = null; - } - - _awaitableEventArgs.SetBuffer(MemoryMarshal.AsMemory(memory)); - - if (!_socket.SendAsync(_awaitableEventArgs)) - { - _awaitableEventArgs.Complete(); - } - - return _awaitableEventArgs; - } - - private List> GetBufferList(in ReadOnlySequence buffer) - { - Debug.Assert(!buffer.IsEmpty); - Debug.Assert(!buffer.IsSingleSegment); - - if (_bufferList == null) - { - _bufferList = new List>(); - } - else - { - // Buffers are pooled, so it's OK to root them until the next multi-buffer write. - _bufferList.Clear(); - } - - foreach (var b in buffer) - { - _bufferList.Add(b.GetArray()); - } - - return _bufferList; - } - } -} diff --git a/src/Orleans.Core/Networking/Shared/SocketSenderReceiverBase.cs b/src/Orleans.Core/Networking/Shared/SocketSenderReceiverBase.cs deleted file mode 100644 index 4395971ac3d..00000000000 --- a/src/Orleans.Core/Networking/Shared/SocketSenderReceiverBase.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.IO.Pipelines; -using System.Net.Sockets; - -namespace Orleans.Networking.Shared -{ - internal abstract class SocketSenderReceiverBase : IDisposable - { - protected readonly Socket _socket; - protected readonly SocketAwaitableEventArgs _awaitableEventArgs; - - protected SocketSenderReceiverBase(Socket socket, PipeScheduler scheduler) - { - _socket = socket; - _awaitableEventArgs = new SocketAwaitableEventArgs(scheduler); - } - - public void Dispose() => _awaitableEventArgs.Dispose(); - } -} diff --git a/src/Orleans.Core/Networking/Shared/SocketsTrace.cs b/src/Orleans.Core/Networking/Shared/SocketsTrace.cs deleted file mode 100644 index 232c8337565..00000000000 --- a/src/Orleans.Core/Networking/Shared/SocketsTrace.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using Microsoft.Extensions.Logging; - -namespace Orleans.Networking.Shared -{ - internal partial class SocketsTrace : ISocketsTrace - { - // ConnectionRead: Reserved: 3 - private readonly ILogger _logger; - - public SocketsTrace(ILogger logger) - { - _logger = logger; - } - - public void ConnectionRead(string connectionId, int count) - { - // Don't log for now since this could be *too* verbose. - // Reserved: Event ID 3 - } - - [LoggerMessage( - EventId = 6, - Level = LogLevel.Debug, - Message = @"Connection id ""{ConnectionId}"" received FIN." - )] - public partial void ConnectionReadFin(string connectionId); - - [LoggerMessage( - EventId = 7, - Level = LogLevel.Debug, - Message = @"Connection id ""{ConnectionId}"" sending FIN because: ""{Reason}""" - )] - public partial void ConnectionWriteFin(string connectionId, string reason); - - public void ConnectionWrite(string connectionId, int count) - { - // Don't log for now since this could be *too* verbose. - // Reserved: Event ID 11 - } - - public void ConnectionWriteCallback(string connectionId, int status) - { - // Don't log for now since this could be *too* verbose. - // Reserved: Event ID 12 - } - - [LoggerMessage( - EventId = 13, - Level = LogLevel.Debug, - Message = @"Connection id ""{ConnectionId}"" sending FIN." - )] - public partial void ConnectionError(string connectionId, Exception ex); - - [LoggerMessage( - EventId = 19, - Level = LogLevel.Debug, - Message = @"Connection id ""{ConnectionId}"" reset." - )] - public partial void ConnectionReset(string connectionId); - - [LoggerMessage( - EventId = 4, - Level = LogLevel.Debug, - Message = @"Connection id ""{ConnectionId}"" paused." - )] - public partial void ConnectionPause(string connectionId); - - [LoggerMessage( - EventId = 5, - Level = LogLevel.Debug, - Message = @"Connection id ""{ConnectionId}"" resumed." - )] - public partial void ConnectionResume(string connectionId); - - public IDisposable? BeginScope(TState state) where TState : notnull => _logger.BeginScope(state); - - public bool IsEnabled(LogLevel logLevel) => _logger.IsEnabled(logLevel); - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - => _logger.Log(logLevel, eventId, state, exception, formatter); - } -} diff --git a/src/Orleans.Core/Networking/Shared/TransportConnection.Features.cs b/src/Orleans.Core/Networking/Shared/TransportConnection.Features.cs deleted file mode 100644 index 3ec8b016aa8..00000000000 --- a/src/Orleans.Core/Networking/Shared/TransportConnection.Features.cs +++ /dev/null @@ -1,295 +0,0 @@ -using System; -using System.Buffers; -using System.Collections; -using System.Collections.Generic; -using System.IO.Pipelines; -using System.Threading; -using Microsoft.AspNetCore.Connections; -using Microsoft.AspNetCore.Connections.Features; -using Microsoft.AspNetCore.Http.Features; - -namespace Orleans.Networking.Shared -{ - internal interface IConnectionIdFeature - { - string ConnectionId { get; set; } - } - - internal interface IConnectionTransportFeature - { - IDuplexPipe Transport { get; set; } - } - internal interface IConnectionItemsFeature - { - IDictionary Items { get; set; } - } - - internal partial class TransportConnection : IConnectionIdFeature, - IConnectionTransportFeature, - IConnectionItemsFeature, - IMemoryPoolFeature, - IConnectionLifetimeFeature - { - // NOTE: When feature interfaces are added to or removed from this TransportConnection class implementation, - // then the list of `features` in the generated code project MUST also be updated. - // See also: tools/CodeGenerator/TransportConnectionFeatureCollection.cs - - MemoryPool IMemoryPoolFeature.MemoryPool => MemoryPool; - - IDuplexPipe IConnectionTransportFeature.Transport - { - get => Transport; - set => Transport = value; - } - - IDictionary IConnectionItemsFeature.Items - { - get => Items; - set => Items = value; - } - - CancellationToken IConnectionLifetimeFeature.ConnectionClosed - { - get => ConnectionClosed; - set => ConnectionClosed = value; - } - - void IConnectionLifetimeFeature.Abort() => Abort(new ConnectionAbortedException("The connection was aborted by the application via IConnectionLifetimeFeature.Abort().")); - } - - internal partial class TransportConnection : IFeatureCollection - { - private static readonly Type IConnectionIdFeatureType = typeof(IConnectionIdFeature); - private static readonly Type IConnectionTransportFeatureType = typeof(IConnectionTransportFeature); - private static readonly Type IConnectionItemsFeatureType = typeof(IConnectionItemsFeature); - private static readonly Type IMemoryPoolFeatureType = typeof(IMemoryPoolFeature); - private static readonly Type IConnectionLifetimeFeatureType = typeof(IConnectionLifetimeFeature); - - private object? _currentIConnectionIdFeature; - private object? _currentIConnectionTransportFeature; - private object? _currentIConnectionItemsFeature; - private object? _currentIMemoryPoolFeature; - private object? _currentIConnectionLifetimeFeature; - - private int _featureRevision; - - private List>? MaybeExtra; - - private void FastReset() - { - _currentIConnectionIdFeature = this; - _currentIConnectionTransportFeature = this; - _currentIConnectionItemsFeature = this; - _currentIMemoryPoolFeature = this; - _currentIConnectionLifetimeFeature = this; - - } - - // Internal for testing - internal void ResetFeatureCollection() - { - FastReset(); - MaybeExtra?.Clear(); - _featureRevision++; - } - - private object? ExtraFeatureGet(Type key) - { - if (MaybeExtra == null) - { - return null; - } - for (var i = 0; i < MaybeExtra.Count; i++) - { - var kv = MaybeExtra[i]; - if (kv.Key == key) - { - return kv.Value; - } - } - return null; - } - - private void ExtraFeatureSet(Type key, object? value) - { - if (MaybeExtra == null) - { - MaybeExtra = new List>(2); - } - - for (var i = 0; i < MaybeExtra.Count; i++) - { - if (MaybeExtra[i].Key == key) - { - MaybeExtra[i] = new KeyValuePair(key, value!); - return; - } - } - MaybeExtra.Add(new KeyValuePair(key, value!)); - } - - bool IFeatureCollection.IsReadOnly => false; - - int IFeatureCollection.Revision => _featureRevision; - - object? IFeatureCollection.this[Type key] - { - get - { - object? feature = null; - if (key == IConnectionIdFeatureType) - { - feature = _currentIConnectionIdFeature; - } - else if (key == IConnectionTransportFeatureType) - { - feature = _currentIConnectionTransportFeature; - } - else if (key == IConnectionItemsFeatureType) - { - feature = _currentIConnectionItemsFeature; - } - else if (key == IMemoryPoolFeatureType) - { - feature = _currentIMemoryPoolFeature; - } - else if (key == IConnectionLifetimeFeatureType) - { - feature = _currentIConnectionLifetimeFeature; - } - else if (MaybeExtra != null) - { - feature = ExtraFeatureGet(key); - } - - return feature; - } - - set - { - _featureRevision++; - - if (key == IConnectionIdFeatureType) - { - _currentIConnectionIdFeature = value; - } - else if (key == IConnectionTransportFeatureType) - { - _currentIConnectionTransportFeature = value; - } - else if (key == IConnectionItemsFeatureType) - { - _currentIConnectionItemsFeature = value; - } - else if (key == IMemoryPoolFeatureType) - { - _currentIMemoryPoolFeature = value; - } - else if (key == IConnectionLifetimeFeatureType) - { - _currentIConnectionLifetimeFeature = value; - } - else - { - ExtraFeatureSet(key, value); - } - } - } - - TFeature? IFeatureCollection.Get() where TFeature : default - { - TFeature? feature = default; - if (typeof(TFeature) == typeof(IConnectionIdFeature)) - { - feature = (TFeature?)_currentIConnectionIdFeature; - } - else if (typeof(TFeature) == typeof(IConnectionTransportFeature)) - { - feature = (TFeature?)_currentIConnectionTransportFeature; - } - else if (typeof(TFeature) == typeof(IConnectionItemsFeature)) - { - feature = (TFeature?)_currentIConnectionItemsFeature; - } - else if (typeof(TFeature) == typeof(IMemoryPoolFeature)) - { - feature = (TFeature?)_currentIMemoryPoolFeature; - } - else if (typeof(TFeature) == typeof(IConnectionLifetimeFeature)) - { - feature = (TFeature?)_currentIConnectionLifetimeFeature; - } - else if (MaybeExtra != null) - { - feature = (TFeature?)ExtraFeatureGet(typeof(TFeature)); - } - - return feature; - } - - void IFeatureCollection.Set(TFeature? feature) where TFeature : default - { - _featureRevision++; - if (typeof(TFeature) == typeof(IConnectionIdFeature)) - { - _currentIConnectionIdFeature = feature; - } - else if (typeof(TFeature) == typeof(IConnectionTransportFeature)) - { - _currentIConnectionTransportFeature = feature; - } - else if (typeof(TFeature) == typeof(IConnectionItemsFeature)) - { - _currentIConnectionItemsFeature = feature; - } - else if (typeof(TFeature) == typeof(IMemoryPoolFeature)) - { - _currentIMemoryPoolFeature = feature; - } - else if (typeof(TFeature) == typeof(IConnectionLifetimeFeature)) - { - _currentIConnectionLifetimeFeature = feature; - } - else - { - ExtraFeatureSet(typeof(TFeature), feature); - } - } - - private IEnumerable> FastEnumerable() - { - if (_currentIConnectionIdFeature != null) - { - yield return new KeyValuePair(IConnectionIdFeatureType, _currentIConnectionIdFeature); - } - if (_currentIConnectionTransportFeature != null) - { - yield return new KeyValuePair(IConnectionTransportFeatureType, _currentIConnectionTransportFeature); - } - if (_currentIConnectionItemsFeature != null) - { - yield return new KeyValuePair(IConnectionItemsFeatureType, _currentIConnectionItemsFeature); - } - if (_currentIMemoryPoolFeature != null) - { - yield return new KeyValuePair(IMemoryPoolFeatureType, _currentIMemoryPoolFeature); - } - if (_currentIConnectionLifetimeFeature != null) - { - yield return new KeyValuePair(IConnectionLifetimeFeatureType, _currentIConnectionLifetimeFeature); - } - - if (MaybeExtra != null) - { - foreach (var item in MaybeExtra) - { - yield return item; - } - } - } - - IEnumerator> IEnumerable>.GetEnumerator() => FastEnumerable().GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() => FastEnumerable().GetEnumerator(); - } -} diff --git a/src/Orleans.Core/Networking/Shared/TransportConnection.cs b/src/Orleans.Core/Networking/Shared/TransportConnection.cs deleted file mode 100644 index 8bf1ed9f436..00000000000 --- a/src/Orleans.Core/Networking/Shared/TransportConnection.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Buffers; -using System.Collections.Generic; -using System.IO.Pipelines; -using System.Net; -using System.Threading; -using Microsoft.AspNetCore.Connections; -using Microsoft.AspNetCore.Http.Features; - -namespace Orleans.Networking.Shared -{ - internal abstract partial class TransportConnection : ConnectionContext - { - private IDictionary? _items; - private string? _connectionId; - - public TransportConnection() - { - FastReset(); - } - - public override EndPoint? LocalEndPoint { get; set; } - public override EndPoint? RemoteEndPoint { get; set; } - - public override string ConnectionId - { - get - { - if (_connectionId == null) - { - _connectionId = CorrelationIdGenerator.GetNextId(); - } - - return _connectionId; - } - set - { - _connectionId = value; - } - } - - public override IFeatureCollection Features => this; - - public virtual MemoryPool MemoryPool { get; } = null!; - - public override IDuplexPipe Transport { get; set; } = null!; - - public IDuplexPipe Application { get; set; } = null!; - - public override IDictionary Items - { - get - { - // Lazily allocate connection metadata - return _items ?? (_items = new ConnectionItems()); - } - set - { - _items = value; - } - } - - public override CancellationToken ConnectionClosed { get; set; } - - // DO NOT remove this override to ConnectionContext.Abort. Doing so would cause - // any TransportConnection that does not override Abort or calls base.Abort - // to stack overflow when IConnectionLifetimeFeature.Abort() is called. - // That said, all derived types should override this method should override - // this implementation of Abort because canceling pending output reads is not - // sufficient to abort the connection if there is backpressure. - public override void Abort(ConnectionAbortedException abortReason) - { - Application.Input.CancelPendingRead(); - } - } -} diff --git a/src/Orleans.Core/Networking/SocketDirection.cs b/src/Orleans.Core/Networking/SocketDirection.cs deleted file mode 100644 index 24a042ebe3f..00000000000 --- a/src/Orleans.Core/Networking/SocketDirection.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Orleans.Messaging -{ - internal enum ConnectionDirection : byte - { - SiloToSilo, - ClientToGateway, - GatewayToClient - } -} diff --git a/src/Orleans.Core/Networking/Transport/ConnectionAbortedException.cs b/src/Orleans.Core/Networking/Transport/ConnectionAbortedException.cs new file mode 100644 index 00000000000..68cba17e005 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/ConnectionAbortedException.cs @@ -0,0 +1,69 @@ +using System; +using System.Runtime.Serialization; + +namespace Orleans.Connections.Transport; + +/// +/// Indicates that a transport connection was terminated before initialization or a pending operation could complete. +/// +[Serializable] +public class ConnectionAbortedException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public ConnectionAbortedException() + { + } + + /// + /// Initializes a new instance of the class with a message describing the failure. + /// + /// The message describing the failure. + public ConnectionAbortedException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a message and the underlying cause. + /// + /// The message describing the failure. + /// The exception which caused the connection to be aborted. + public ConnectionAbortedException(string? message, Exception? innerException) : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class from serialized data. + /// + /// The serialized exception data. + /// The serialization context. + [Obsolete] + protected ConnectionAbortedException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } +} + +/// +/// Indicates that a connection closed normally. +/// +[Serializable] +public class ConnectionClosedException : Exception +{ + public ConnectionClosedException() + { + } + + public ConnectionClosedException(string? message) : base(message) + { + } + + public ConnectionClosedException(string? message, Exception? innerException) : base(message, innerException) + { + } + + [Obsolete] + protected ConnectionClosedException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } +} diff --git a/src/Orleans.Core/Networking/Transport/ConnectionEndPointFeature.cs b/src/Orleans.Core/Networking/Transport/ConnectionEndPointFeature.cs new file mode 100644 index 00000000000..2855989a1c4 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/ConnectionEndPointFeature.cs @@ -0,0 +1,26 @@ +using System.Net; + +namespace Orleans.Connections.Transport; + +/// +/// Exposes local and remote endpoints for a . +/// +public interface IConnectionEndPointFeature +{ + /// + /// Gets or sets the local endpoint. + /// + EndPoint? LocalEndPoint { get; set; } + + /// + /// Gets or sets the remote endpoint. + /// + EndPoint? RemoteEndPoint { get; set; } +} + +internal sealed class ConnectionEndPointFeature : IConnectionEndPointFeature +{ + public EndPoint? LocalEndPoint { get; set; } + + public EndPoint? RemoteEndPoint { get; set; } +} diff --git a/src/Orleans.Core/Networking/Transport/ConnectionResetException.cs b/src/Orleans.Core/Networking/Transport/ConnectionResetException.cs new file mode 100644 index 00000000000..64bae57a21a --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/ConnectionResetException.cs @@ -0,0 +1,45 @@ +using System; +using System.Runtime.Serialization; + +namespace Orleans.Connections.Transport; + +/// +/// Indicates that a transport connection was terminated by a reset from the remote peer or the underlying network. +/// +[Serializable] +public class ConnectionResetException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public ConnectionResetException() + { + } + + /// + /// Initializes a new instance of the class with a message describing the failure. + /// + /// The message describing the failure. + public ConnectionResetException(string? message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a message and the underlying cause. + /// + /// The message describing the failure. + /// The exception which caused the connection to be reset. + public ConnectionResetException(string? message, Exception? innerException) : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class from serialized data. + /// + /// The serialized exception data. + /// The serialization context. + [Obsolete] + protected ConnectionResetException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } +} diff --git a/src/Orleans.Core/Networking/Transport/FeatureCollection.cs b/src/Orleans.Core/Networking/Transport/FeatureCollection.cs new file mode 100644 index 00000000000..eaeaa845995 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/FeatureCollection.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Orleans.Connections.Transport; + +/// +/// Default implementation for . +/// +public class FeatureCollection : IFeatureCollection +{ + private static readonly KeyComparer FeatureKeyComparer = new KeyComparer(); + private readonly IFeatureCollection? _defaults; + private readonly int _initialCapacity; + private IDictionary? _features; + private volatile int _containerRevision; + + /// + /// Initializes a new instance of . + /// + public FeatureCollection() + { + } + + /// + /// Initializes a new instance of with the specified initial capacity. + /// + /// The initial number of elements that the collection can contain. + /// is less than 0 + public FeatureCollection(int initialCapacity) + { + if (initialCapacity < 0) + { + throw new ArgumentOutOfRangeException(nameof(initialCapacity)); + } + + _initialCapacity = initialCapacity; + } + + /// + /// Initializes a new instance of with the specified defaults. + /// + /// The feature defaults. + public FeatureCollection(IFeatureCollection defaults) + { + _defaults = defaults; + } + + /// + public virtual int Revision => _containerRevision + (_defaults?.Revision ?? 0); + + /// + public bool IsReadOnly => false; + + /// + public object? this[Type key] + { + get + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + return _features != null && _features.TryGetValue(key, out var result) ? result : _defaults?[key]; + } + + set + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (value == null) + { + if (_features != null && _features.Remove(key)) + { + _containerRevision++; + } + return; + } + + _features ??= new Dictionary(_initialCapacity); + _features[key] = value; + _containerRevision++; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + public IEnumerator> GetEnumerator() + { + if (_features != null) + { + foreach (var pair in _features) + { + yield return pair; + } + } + + if (_defaults != null) + { + // Don't return features masked by the wrapper. + foreach (var pair in _features == null ? _defaults : _defaults.Except(_features, FeatureKeyComparer)) + { + yield return pair; + } + } + } + + /// + public TFeature? Get() => (TFeature?)this[typeof(TFeature)]; + + /// + public void Set(TFeature? instance) => this[typeof(TFeature)] = instance; + + private sealed class KeyComparer : IEqualityComparer> + { + public bool Equals(KeyValuePair x, KeyValuePair y) => x.Key.Equals(y.Key); + + public int GetHashCode(KeyValuePair obj) => obj.Key.GetHashCode(); + } +} diff --git a/src/Orleans.Core/Networking/Transport/IFeatureCollection.cs b/src/Orleans.Core/Networking/Transport/IFeatureCollection.cs new file mode 100644 index 00000000000..588376b0c0f --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/IFeatureCollection.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace Orleans.Connections.Transport; + +/// +/// Represents a collection of typed features. +/// +public interface IFeatureCollection : IEnumerable> +{ + /// + /// Indicates if the collection can be modified. + /// + bool IsReadOnly { get; } + + /// + /// Incremented for each modification and can be used to verify cached results. + /// + int Revision { get; } + + /// + /// Gets or sets a given feature. Setting a null value removes the feature. + /// + /// + /// The requested feature, or null if it is not present. + object? this[Type key] { get; set; } + + /// + /// Retrieves the requested feature from the collection. + /// + /// The feature key. + /// The requested feature, or null if it is not present. + TFeature? Get(); + + /// + /// Sets the given feature in the collection. + /// + /// The feature key. + /// The feature value. + void Set(TFeature? instance); +} diff --git a/src/Orleans.Core/Networking/Transport/MessageTransport.cs b/src/Orleans.Core/Networking/Transport/MessageTransport.cs new file mode 100644 index 00000000000..8e93a916073 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/MessageTransport.cs @@ -0,0 +1,108 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace Orleans.Connections.Transport; + +/// +/// Represents a bi-directional communication channel between two hosts. +/// +public abstract class MessageTransport : IAsyncDisposable +{ + /// + /// Gets the cancellation token which is canceled once the connection is closed. + /// + public abstract CancellationToken Closed { get; } + + /// + /// Gets the collection of features available on the transport. + /// + public abstract IFeatureCollection Features { get; } + + /// + /// Submits a read request to the channel. + /// + /// The read request. + /// if the read request was accepted by the channel, if it was rejected. + public abstract bool EnqueueRead(ReadRequest request); + + /// + /// Submits a write request to the channel. + /// + /// The write request. + /// if the write request was accepted by the channel, if it was rejected. + public abstract bool EnqueueWrite(WriteRequest request); + + /// + /// Closes the channel, optionally with a provided exception. + /// + /// The channel close exception, which is propagated to requests. + /// A cancellation token that can be used to force immediate shutdown. + /// A which completes once the channel has been closed. + public abstract ValueTask CloseAsync(Exception? closeException, CancellationToken cancellationToken = default); + + /// + public virtual ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return default; + } +} + +/// +/// Creates instances which are connected to a specified endpoint. +/// +public abstract class MessageTransportConnector : IAsyncDisposable +{ + /// + /// Gets the collection of features available on the transport factory. + /// + public abstract IFeatureCollection Features { get; } + + /// + /// Gets a value indicating whether this connector is valid for use. + /// + public abstract bool IsValid { get; } + + /// + /// Creates a connected to the specified . + /// + /// The endpoint to connect to. + /// The cancellation token. + /// The connected message transport. + public abstract ValueTask CreateAsync(EndPoint endpoint, CancellationToken cancellationToken = default); + + /// + public virtual ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return default; + } +} + +/// +/// Middleware which operates on instances. +/// +public interface IMessageTransportConnectorMiddleware +{ + /// + /// Applies this middleware to the provided transport connector. + /// + /// The transport connector. + /// The transport factory with this middleware applied to it. + MessageTransportConnector Apply(MessageTransportConnector transport); +} + +/// +/// Middleware which operates on instances. +/// +public interface IMessageTransportListenerMiddleware +{ + /// + /// Applies this middleware to the provided listener. + /// + /// The listener. + /// The listener with this middleware applied to it. + MessageTransportListener Apply(MessageTransportListener listener); +} diff --git a/src/Orleans.Core/Networking/Transport/MessageTransportBase.cs b/src/Orleans.Core/Networking/Transport/MessageTransportBase.cs new file mode 100644 index 00000000000..1a064ddf3fb --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/MessageTransportBase.cs @@ -0,0 +1,12 @@ +namespace Orleans.Connections.Transport; + +/// +/// Base class for implementations. +/// +public abstract class MessageTransportBase : MessageTransport +{ + /// + /// Gets the features of this transport. + /// + public override FeatureCollection Features { get; } = new FeatureCollection(); +} diff --git a/src/Orleans.Core/Networking/Transport/MessageTransportListener.cs b/src/Orleans.Core/Networking/Transport/MessageTransportListener.cs new file mode 100644 index 00000000000..2423bde6ac8 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/MessageTransportListener.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Orleans.Connections.Transport; + +/// +/// Represents a message transport listener, which provides active message transports. +/// +public abstract class MessageTransportListener : IAsyncDisposable +{ + /// + /// Gets a value indicating whether this instance is valid and should be used. + /// + public abstract bool IsValid { get; } + + /// + /// Gets the name of the listener. + /// + public abstract string ListenerName { get; } + + /// + /// Gets the collection of features available on the listener. + /// + public abstract IFeatureCollection Features { get; } + + /// + /// Binds to the configured endpoint and begins listening for incoming connections. + /// + /// The cancellation token. + /// A which completes once the listener has been bound. + public abstract ValueTask BindAsync(CancellationToken cancellationToken = default); + + /// + /// Accepts an incoming connection. + /// + /// The cancellation token. + /// The message transport, or if the listener has been stopped. + public abstract ValueTask AcceptAsync(CancellationToken cancellationToken = default); + + /// + /// Unbinds from the configured endpoint. + /// + /// The cancellation token. + /// A representing the operation. + public abstract ValueTask UnbindAsync(CancellationToken cancellationToken = default); + + /// + public virtual ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return default; + } +} diff --git a/src/Orleans.Core/Networking/Transport/ReadRequest.cs b/src/Orleans.Core/Networking/Transport/ReadRequest.cs new file mode 100644 index 00000000000..830ad550648 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/ReadRequest.cs @@ -0,0 +1,31 @@ +using System; +using Orleans.Serialization.Buffers; + +namespace Orleans.Connections.Transport; + +/// +/// Represents a read operation submitted to a . +/// +/// +/// Once accepted by a transport, the request remains transport-owned until one of its terminal callbacks completes. +/// +public abstract class ReadRequest +{ + /// + /// Consumes available input for this request. + /// + /// The available input. Implementations advance this reader as bytes are consumed. + /// when the request is complete; otherwise, to await more input. + public abstract bool OnRead(ArcBufferReader buffer); + + /// + /// Completes the request with an error. + /// + /// The error which terminated the read. + public abstract void OnError(Exception error); + + /// + /// Completes the request because the transport was canceled or closed. + /// + public abstract void OnCanceled(); +} diff --git a/src/Orleans.Core/Networking/Transport/Security/CertificateLoader.cs b/src/Orleans.Core/Networking/Transport/Security/CertificateLoader.cs new file mode 100644 index 00000000000..41780104b92 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/CertificateLoader.cs @@ -0,0 +1,123 @@ +using System; +using System.Linq; +using System.Security.Cryptography.X509Certificates; + +namespace Orleans.Connections.Transport.Security; + +/// +/// Provides methods for loading TLS certificates from an operating system certificate store. +/// +public static class CertificateLoader +{ + // See http://oid-info.com/get/1.3.6.1.5.5.7.3.1 + // Indicates that a certificate can be used as a TLS server certificate + private const string ServerAuthenticationOid = "1.3.6.1.5.5.7.3.1"; + + // See http://oid-info.com/get/1.3.6.1.5.5.7.3.2 + // Indicates that a certificate can be used as a TLS client certificate + private const string ClientAuthenticationOid = "1.3.6.1.5.5.7.3.2"; + + /// + /// Loads the newest matching certificate which has an accessible private key and is valid for the requested TLS role. + /// + /// The subject name to search for. + /// The name of the certificate store. + /// The location of the certificate store. + /// + /// to include certificates which do not pass certificate-chain validation; + /// otherwise, . + /// + /// + /// to select a certificate valid for server authentication; + /// to select one valid for client authentication. + /// + /// The selected certificate. The caller owns and disposes the certificate after its consumers have stopped. + /// No suitable certificate was found. + public static X509Certificate2 LoadFromStoreCert(string subject, string storeName, StoreLocation storeLocation, bool allowInvalid, bool server) + { + using (var store = new X509Store(storeName, storeLocation)) + { + X509Certificate2Collection? storeCertificates = null; + X509Certificate2? foundCertificate = null; + + try + { + store.Open(OpenFlags.ReadOnly); + storeCertificates = store.Certificates; + var foundCertificates = storeCertificates.Find(X509FindType.FindBySubjectName, subject, !allowInvalid); + foundCertificate = foundCertificates + .OfType() + .Where(c => server ? IsCertificateAllowedForServerAuth(c) : IsCertificateAllowedForClientAuth(c)) + .Where(DoesCertificateHaveAnAccessiblePrivateKey) + .OrderByDescending(certificate => certificate.NotAfter) + .FirstOrDefault(); + + if (foundCertificate == null) + { + throw new InvalidOperationException($"Certificate {subject} not found in store {storeLocation} / {storeName}. AllowInvalid: {allowInvalid}"); + } + + return foundCertificate; + } + finally + { + DisposeCertificates(storeCertificates, except: foundCertificate); + } + } + } + + internal static bool IsCertificateAllowedForServerAuth(X509Certificate2 certificate) => IsCertificateAllowedForKeyUsage(certificate, ServerAuthenticationOid); + + internal static bool IsCertificateAllowedForClientAuth(X509Certificate2 certificate) => IsCertificateAllowedForKeyUsage(certificate, ClientAuthenticationOid); + + private static bool IsCertificateAllowedForKeyUsage(X509Certificate2 certificate, string purposeOid) + { + /* If the Extended Key Usage extension is included, then we check that the serverAuth usage is included. (http://oid-info.com/get/1.3.6.1.5.5.7.3.1) + * If the Extended Key Usage extension is not included, then we assume the certificate is allowed for all usages. + * + * See also https://blogs.msdn.microsoft.com/kaushal/2012/02/17/client-certificates-vs-server-certificates/ + * + * From https://tools.ietf.org/html/rfc3280#section-4.2.1.13 "Certificate Extensions: Extended Key Usage" + * + * If the (Extended Key Usage) extension is present, then the certificate MUST only be used + * for one of the purposes indicated. If multiple purposes are + * indicated the application need not recognize all purposes indicated, + * as long as the intended purpose is present. Certificate using + * applications MAY require that a particular purpose be indicated in + * order for the certificate to be acceptable to that application. + */ + + var hasEkuExtension = false; + + foreach (var extension in certificate.Extensions.OfType()) + { + hasEkuExtension = true; + foreach (var oid in extension.EnhancedKeyUsages) + { + if (oid.Value is not null && oid.Value.Equals(purposeOid, StringComparison.Ordinal)) + { + return true; + } + } + } + + return !hasEkuExtension; + } + + internal static bool DoesCertificateHaveAnAccessiblePrivateKey(X509Certificate2 certificate) + => certificate.HasPrivateKey; + + private static void DisposeCertificates(X509Certificate2Collection? certificates, X509Certificate2? except) + { + if (certificates != null) + { + foreach (var certificate in certificates) + { + if (!certificate.Equals(except)) + { + certificate.Dispose(); + } + } + } + } +} diff --git a/src/Orleans.Core/Networking/Transport/Security/ClientTlsMessageTransport.cs b/src/Orleans.Core/Networking/Transport/Security/ClientTlsMessageTransport.cs new file mode 100644 index 00000000000..29b8f2cc104 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/ClientTlsMessageTransport.cs @@ -0,0 +1,104 @@ +using System; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Orleans.Connections.Transport.Security; + +/// +/// Message transport encrypts and decrypts all data using TLS, authenticating with the remote endpoint as a client. +/// +internal sealed class ClientTlsMessageTransport : TlsMessageTransport +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Usage", + "CA2213:Disposable fields should be disposed", + Justification = "The certificate is supplied and owned by the caller's TLS options.")] + private readonly X509Certificate2? _certificate; + private readonly Func? _certificateSelector; + + public ClientTlsMessageTransport(MessageTransport transport, TlsOptions options, ILogger logger) : base(transport, options, logger) + { + // Capture the certificate now so it can't be switched after validation + _certificate = options.LocalCertificate; + _certificateSelector = options.LocalClientCertificateSelector; + + // If a selector is provided then ignore the cert, it may be a default cert. + if (_certificateSelector is not null) + { + // SslStream doesn't allow both. + _certificate = null; + } + else if (_certificate is not null) + { + _certificate = ValidateCertificate(_certificate, options.ClientCertificateMode); + } + + if (_certificate is null && _certificateSelector is null && options.ClientCertificateMode == RemoteCertificateMode.RequireCertificate) + { + throw new InvalidOperationException($"Either {nameof(TlsOptions)}.{nameof(TlsOptions.LocalCertificate)} or {nameof(TlsOptions)}.{nameof(TlsOptions.LocalClientCertificateSelector)} must be set to a non-null" + + $" value because {nameof(TlsOptions)}.{nameof(TlsOptions.ClientCertificateMode)} is set to {nameof(RemoteCertificateMode)}.{nameof(RemoteCertificateMode.RequireCertificate)}."); + } + } + + protected override async Task AuthenticateAsyncCore(MessageTransport transport, bool certificateRequired, CancellationToken cancellationToken) + { + ClientCertificateSelectionCallback? selector = null; + if (_certificateSelector != null) + { + selector = (sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers) => + { + var cert = _certificateSelector(sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers); + return ValidateCertificate(cert, Options.ClientCertificateMode); + }; + } + + var sslOptions = new TlsClientAuthenticationOptions + { + ClientCertificates = _certificate == null || _certificateSelector != null ? null : new X509CertificateCollection { _certificate }, + LocalCertificateSelectionCallback = selector, + EnabledSslProtocols = Options.SslProtocols, + CertificateRevocationCheckMode = Options.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, + }; + + Options.OnAuthenticateAsClient?.Invoke(transport, sslOptions); + + await Stream.AuthenticateAsClientAsync(sslOptions.Value, cancellationToken); + } + + private static X509Certificate2? ValidateCertificate(X509Certificate2? certificate, RemoteCertificateMode mode) + { + switch (mode) + { + case RemoteCertificateMode.NoCertificate: + return null; + case RemoteCertificateMode.AllowCertificate: + // If certificate exists but can not be used for client authentication. + if (certificate != null && CertificateLoader.IsCertificateAllowedForClientAuth(certificate)) + { + return certificate; + } + + return null; + case RemoteCertificateMode.RequireCertificate: + EnsureCertificateIsAllowedForClientAuth(certificate); + return certificate; + default: + throw new ArgumentOutOfRangeException(nameof(mode), mode, null); + } + } + + private static void EnsureCertificateIsAllowedForClientAuth(X509Certificate2? certificate) + { + if (certificate is null) + { + throw new InvalidOperationException("No certificate provided for client authentication."); + } + + if (!CertificateLoader.IsCertificateAllowedForClientAuth(certificate)) + { + throw new InvalidOperationException($"Invalid client certificate for client authentication: {certificate.Thumbprint}"); + } + } +} diff --git a/src/Orleans.Core/Networking/Transport/Security/ITlsApplicationProtocolFeature.cs b/src/Orleans.Core/Networking/Transport/Security/ITlsApplicationProtocolFeature.cs new file mode 100644 index 00000000000..869bdbf3ad7 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/ITlsApplicationProtocolFeature.cs @@ -0,0 +1,14 @@ +using System; + +namespace Orleans.Connections.Transport.Security; + +/// +/// Provides access to the negotiated TLS application protocol. +/// +public interface ITlsApplicationProtocolFeature +{ + /// + /// Gets the negotiated TLS application protocol. + /// + ReadOnlyMemory ApplicationProtocol { get; } +} diff --git a/src/Orleans.Core/Networking/Transport/Security/ITlsConnectionFeature.cs b/src/Orleans.Core/Networking/Transport/Security/ITlsConnectionFeature.cs new file mode 100644 index 00000000000..91748232245 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/ITlsConnectionFeature.cs @@ -0,0 +1,23 @@ +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; + +namespace Orleans.Connections.Transport.Security; + +/// +/// Provides access to TLS connection certificate information. +/// +public interface ITlsConnectionFeature +{ + /// + /// Gets or sets the remote endpoint's certificate, if any. + /// + X509Certificate2? RemoteCertificate { get; set; } + + /// + /// Asynchronously retrieves the remote endpoint's certificate, if any. + /// + /// The cancellation token. + /// The remote endpoint certificate, or if none is available. + Task GetRemoteCertificateAsync(CancellationToken cancellationToken); +} diff --git a/src/Orleans.Core/Networking/Transport/Security/ITlsHandshakeFeature.cs b/src/Orleans.Core/Networking/Transport/Security/ITlsHandshakeFeature.cs new file mode 100644 index 00000000000..b7d0adcea05 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/ITlsHandshakeFeature.cs @@ -0,0 +1,56 @@ +using System; +using System.Net.Security; +using System.Security.Authentication; + +namespace Orleans.Connections.Transport.Security; + +/// +/// Provides access to TLS handshake information. +/// +public interface ITlsHandshakeFeature +{ + /// + /// Gets the negotiated TLS protocol. + /// + SslProtocols Protocol { get; } + + /// + /// Gets the negotiated TLS cipher suite. + /// + TlsCipherSuite? NegotiatedCipherSuite => null; + + /// + /// Gets the host name from the TLS server_name (SNI) extension, if present. + /// + string HostName => string.Empty; + +#if NET10_0_OR_GREATER + [Obsolete("KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherStrength, HashAlgorithm and HashStrength properties are obsolete. Use NegotiatedCipherSuite instead.", DiagnosticId = "SYSLIB0058", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] +#endif + CipherAlgorithmType CipherAlgorithm { get; } + +#if NET10_0_OR_GREATER + [Obsolete("KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherStrength, HashAlgorithm and HashStrength properties are obsolete. Use NegotiatedCipherSuite instead.", DiagnosticId = "SYSLIB0058", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] +#endif + int CipherStrength { get; } + +#if NET10_0_OR_GREATER + [Obsolete("KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherStrength, HashAlgorithm and HashStrength properties are obsolete. Use NegotiatedCipherSuite instead.", DiagnosticId = "SYSLIB0058", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] +#endif + HashAlgorithmType HashAlgorithm { get; } + +#if NET10_0_OR_GREATER + [Obsolete("KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherStrength, HashAlgorithm and HashStrength properties are obsolete. Use NegotiatedCipherSuite instead.", DiagnosticId = "SYSLIB0058", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] +#endif + int HashStrength { get; } + +#if NET10_0_OR_GREATER + [Obsolete("KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherStrength, HashAlgorithm and HashStrength properties are obsolete. Use NegotiatedCipherSuite instead.", DiagnosticId = "SYSLIB0058", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] +#endif + ExchangeAlgorithmType KeyExchangeAlgorithm { get; } + +#if NET10_0_OR_GREATER + [Obsolete("KeyExchangeAlgorithm, KeyExchangeStrength, CipherAlgorithm, CipherStrength, HashAlgorithm and HashStrength properties are obsolete. Use NegotiatedCipherSuite instead.", DiagnosticId = "SYSLIB0058", UrlFormat = "https://aka.ms/dotnet-warnings/{0}")] +#endif + int KeyExchangeStrength { get; } +} diff --git a/src/Orleans.Core/Networking/Transport/Security/RemoteCertificateMode.cs b/src/Orleans.Core/Networking/Transport/Security/RemoteCertificateMode.cs new file mode 100644 index 00000000000..cdc21a71e00 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/RemoteCertificateMode.cs @@ -0,0 +1,22 @@ +namespace Orleans.Connections.Transport.Security; + +/// +/// Describes the remote certificate requirements for a TLS connection. +/// +public enum RemoteCertificateMode +{ + /// + /// A remote certificate is not required and will not be requested from remote endpoints. + /// + NoCertificate, + + /// + /// A remote certificate will be requested; however, authentication will not fail if a certificate is not provided by the remote endpoint. + /// + AllowCertificate, + + /// + /// A remote certificate will be requested, and the remote endpoint must provide a valid certificate for authentication. + /// + RequireCertificate +} diff --git a/src/Orleans.Core/Networking/Transport/Security/ServerTlsMessageTransport.cs b/src/Orleans.Core/Networking/Transport/Security/ServerTlsMessageTransport.cs new file mode 100644 index 00000000000..0f91cce00ec --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/ServerTlsMessageTransport.cs @@ -0,0 +1,89 @@ +using System; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Orleans.Connections.Transport.Security; + +/// +/// Message transport encrypts and decrypts all data using TLS, authenticating with the remote endpoint as a server. +/// +internal sealed class ServerTlsMessageTransport : TlsMessageTransport +{ + private readonly X509Certificate2? _certificate; + private readonly Func? _certificateSelector; + + public ServerTlsMessageTransport(MessageTransport transport, TlsOptions options, ILogger logger) : base(transport, options, logger) + { + // Capture the certificate now so it can't be switched after validation + _certificate = options.LocalCertificate; + _certificateSelector = options.LocalServerCertificateSelector; + + if (_certificate is null && _certificateSelector is null) + { + throw new InvalidOperationException($"Either {nameof(TlsOptions)}.{nameof(TlsOptions.LocalCertificate)} or {nameof(TlsOptions)}.{nameof(TlsOptions.LocalServerCertificateSelector)} must be set to a non-null value."); + } + + // If a selector is provided then ignore the cert, it may be a default cert. + if (_certificateSelector is not null) + { + // SslStream doesn't allow both. + _certificate = null; + } + else if (_certificate is not null) + { + EnsureCertificateIsAllowedForServerAuth(_certificate); + } + } + + protected override async Task AuthenticateAsyncCore(MessageTransport transport, bool certificateRequired, CancellationToken cancellationToken) + { + // Adapt to the SslStream signature + ServerCertificateSelectionCallback? selector = null; + if (_certificateSelector != null) + { + selector = (sender, name) => + { + TlsConnectionFeature.HostName = name ?? string.Empty; + var cert = _certificateSelector(transport, name); + if (cert != null) + { + EnsureCertificateIsAllowedForServerAuth(cert); + } + + return cert; + }; + } + else if (_certificate != null) + { + // Even with a fixed certificate, capture the client's SNI host name. + selector = (sender, name) => + { + TlsConnectionFeature.HostName = name ?? string.Empty; + return _certificate; + }; + } + + var sslOptions = new TlsServerAuthenticationOptions + { + ServerCertificate = selector is null ? _certificate : null, + ServerCertificateSelectionCallback = selector, + ClientCertificateRequired = certificateRequired, + EnabledSslProtocols = Options.SslProtocols, + CertificateRevocationCheckMode = Options.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, + }; + + Options.OnAuthenticateAsServer?.Invoke(transport, sslOptions); + + await Stream.AuthenticateAsServerAsync(sslOptions.Value, cancellationToken); + } + + private static void EnsureCertificateIsAllowedForServerAuth(X509Certificate2 certificate) + { + if (!CertificateLoader.IsCertificateAllowedForServerAuth(certificate)) + { + throw new InvalidOperationException($"Invalid server certificate for server authentication: {certificate.Thumbprint}"); + } + } +} diff --git a/src/Orleans.Core/Networking/Transport/Security/TlsClientAuthenticationOptions.cs b/src/Orleans.Core/Networking/Transport/Security/TlsClientAuthenticationOptions.cs new file mode 100644 index 00000000000..db633e82542 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/TlsClientAuthenticationOptions.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; + +namespace Orleans.Connections.Transport.Security; + +/// +/// Selects the local certificate presented during a TLS client handshake. +/// +/// The performing the handshake. +/// The name of the remote host. +/// The available local certificates. +/// The remote certificate, when available. +/// The certificate issuers accepted by the remote host. +/// The certificate to present, or to proceed without a client certificate. +public delegate X509Certificate? ClientCertificateSelectionCallback(object sender, string targetHost, X509CertificateCollection localCertificates, X509Certificate? remoteCertificate, string[] acceptableIssuers); + +/// +/// Configures authentication for an Orleans TLS client handshake. +/// +public class TlsClientAuthenticationOptions +{ + internal SslClientAuthenticationOptions Value { get; } = new SslClientAuthenticationOptions + { + ApplicationProtocols = new List + { + new SslApplicationProtocol("Orleans1") + } + }; + + /// + /// Gets or sets the callback which selects the local client certificate. + /// + public ClientCertificateSelectionCallback? LocalCertificateSelectionCallback + { + get => Value.LocalCertificateSelectionCallback is null ? null : new ClientCertificateSelectionCallback(Value.LocalCertificateSelectionCallback); + set + { +#if NET10_0_OR_GREATER + Value.LocalCertificateSelectionCallback = value is null ? null : new LocalCertificateSelectionCallback(value); +#else + Value.LocalCertificateSelectionCallback = value is null + ? null + : (sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers) => + value(sender, targetHost, localCertificates, remoteCertificate, acceptableIssuers)!; +#endif + } + } + + /// + /// Gets or sets the certificates available for client authentication. + /// + public X509CertificateCollection? ClientCertificates + { + get => Value.ClientCertificates; + set => Value.ClientCertificates = value; + } + + /// + /// Gets or sets the TLS protocol versions allowed for the handshake. + /// + public SslProtocols EnabledSslProtocols + { + get => Value.EnabledSslProtocols; + set => Value.EnabledSslProtocols = value; + } + + /// + /// Gets or sets how the remote certificate's revocation status is checked. + /// + public X509RevocationMode CertificateRevocationCheckMode + { + get => Value.CertificateRevocationCheckMode; + set => Value.CertificateRevocationCheckMode = value; + } + + /// + /// Gets or sets the remote host name used for certificate validation and TLS server name indication. + /// + public string? TargetHost + { + get => Value.TargetHost; + set => Value.TargetHost = value; + } + + /// + /// Gets the underlying for additional handshake configuration. + /// + public object SslClientAuthenticationOptions => Value; +} diff --git a/src/Orleans.Core/Networking/Transport/Security/TlsConnectionFeature.cs b/src/Orleans.Core/Networking/Transport/Security/TlsConnectionFeature.cs new file mode 100644 index 00000000000..82a652f5e63 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/TlsConnectionFeature.cs @@ -0,0 +1,46 @@ +using System; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; + +namespace Orleans.Connections.Transport.Security; + +internal sealed class TlsConnectionFeature : ITlsConnectionFeature, ITlsApplicationProtocolFeature, ITlsHandshakeFeature +{ + public X509Certificate2? LocalCertificate { get; set; } + + public X509Certificate2? RemoteCertificate { get; set; } + + public ReadOnlyMemory ApplicationProtocol { get; set; } + + public SslProtocols Protocol { get; set; } + + public TlsCipherSuite? NegotiatedCipherSuite { get; set; } + + public string HostName { get; set; } = string.Empty; + +#if NET10_0_OR_GREATER +#pragma warning disable SYSLIB0058 +#endif + public CipherAlgorithmType CipherAlgorithm { get; set; } + + public int CipherStrength { get; set; } + + public HashAlgorithmType HashAlgorithm { get; set; } + + public int HashStrength { get; set; } + + public ExchangeAlgorithmType KeyExchangeAlgorithm { get; set; } + + public int KeyExchangeStrength { get; set; } +#if NET10_0_OR_GREATER +#pragma warning restore SYSLIB0058 +#endif + + public Task GetRemoteCertificateAsync(CancellationToken cancellationToken) + { + return Task.FromResult(RemoteCertificate); + } +} diff --git a/src/Orleans.Core/Networking/Transport/Security/TlsMessageTransport.cs b/src/Orleans.Core/Networking/Transport/Security/TlsMessageTransport.cs new file mode 100644 index 00000000000..28b02e74563 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/TlsMessageTransport.cs @@ -0,0 +1,232 @@ +using System; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Orleans.Connections.Transport.Streams; + +namespace Orleans.Connections.Transport.Security; + +/// +/// which encrypts and decrypts all data using TLS. +/// +internal abstract partial class TlsMessageTransport : StreamMessageTransport +{ + private readonly MessageTransport _innerTransport; + private readonly TlsOptions _options; + private readonly ILogger _logger; + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Usage", + "CA2213:Disposable fields should be disposed", + Justification = "SslStream owns and disposes the adapter because leaveInnerStreamOpen is false.")] + private readonly MessageTransportStream _networkTransportStream; + private readonly SslStream _sslStream; + private readonly TlsConnectionFeature _tlsConnectionFeature = new(); + + /// + /// Initializes a new instance. + /// + /// + /// + /// + /// + public TlsMessageTransport(MessageTransport transport, TlsOptions options, ILogger logger) : base(logger) + { + _innerTransport = transport ?? throw new ArgumentNullException(nameof(transport)); + + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger; + Features = new FeatureCollection(_innerTransport.Features); + Features.Set(_tlsConnectionFeature); + Features.Set(_tlsConnectionFeature); + _networkTransportStream = new MessageTransportStream(_innerTransport, _options.MemoryPool); + _sslStream = new SslStream( + _networkTransportStream, + leaveInnerStreamOpen: false, + userCertificateValidationCallback: (sender, certificate, chain, sslPolicyErrors) => + { + if (certificate == null) + { + return _options.RemoteCertificateMode != RemoteCertificateMode.RequireCertificate; + } + + if (_options.RemoteCertificateValidation == null) + { + if (sslPolicyErrors != SslPolicyErrors.None) + { + return false; + } + } + + var certificate2 = ConvertToX509Certificate2(certificate); + if (certificate2 == null) + { + return false; + } + + if (_options.RemoteCertificateValidation != null) + { + if (!_options.RemoteCertificateValidation(certificate2, chain, sslPolicyErrors)) + { + return false; + } + } + + return true; + }); + Features.Set(_sslStream); + } + + /// + public override FeatureCollection Features { get; } + + /// + /// Gets the TLS options. + /// + protected TlsOptions Options => _options; + + /// + /// Gets the underlying . + /// + protected override SslStream Stream => _sslStream; + + private protected TlsConnectionFeature TlsConnectionFeature => _tlsConnectionFeature; + + /// + public override async ValueTask CloseAsync(Exception? closeException, CancellationToken cancellationToken = default) + { + // Close the inner transport first so any pending SslStream I/O unblocks promptly. + await _innerTransport.CloseAsync(closeException, cancellationToken).ConfigureAwait(false); + await base.CloseAsync(closeException, cancellationToken).ConfigureAwait(false); + } + + /// + protected override async Task RunAsyncCore() + { + await AuthenticateAsync().ConfigureAwait(false); + await base.RunAsyncCore().ConfigureAwait(false); + } + + private async Task AuthenticateAsync() + { + bool certificateRequired; + + if (_options.RemoteCertificateMode == RemoteCertificateMode.NoCertificate) + { + certificateRequired = false; + } + else + { + certificateRequired = true; + } + + using (var cancellationTokenSource = _options.CreateHandshakeCancellationTokenSource()) + { + try + { + await AuthenticateAsyncCore(this, certificateRequired, cancellationTokenSource.Token).ConfigureAwait(false); + PopulateTlsConnectionFeature(); + } + catch (OperationCanceledException ex) + { + LogAuthenticationTimedOut(_logger, ex); + await _sslStream.DisposeAsync().ConfigureAwait(false); + await _innerTransport.CloseAsync(ex).ConfigureAwait(false); + throw; + } + catch (Exception ex) + { + LogAuthenticationFailed(_logger, ex); + await _sslStream.DisposeAsync().ConfigureAwait(false); + await _innerTransport.CloseAsync(ex).ConfigureAwait(false); + throw; + } + } + } + + /// + protected abstract Task AuthenticateAsyncCore(MessageTransport transport, bool certificateRequired, CancellationToken cancellationToken); + + private void PopulateTlsConnectionFeature() + { + _tlsConnectionFeature.ApplicationProtocol = _sslStream.NegotiatedApplicationProtocol.Protocol; + Features.Set(_tlsConnectionFeature); + _tlsConnectionFeature.LocalCertificate = ConvertToX509Certificate2(_sslStream.LocalCertificate); + _tlsConnectionFeature.RemoteCertificate = ConvertToX509Certificate2(_sslStream.RemoteCertificate); + _tlsConnectionFeature.NegotiatedCipherSuite = GetOptionalTlsProperty(() => _sslStream.NegotiatedCipherSuite); +#if NET10_0_OR_GREATER +#pragma warning disable SYSLIB0058 +#endif + _tlsConnectionFeature.CipherAlgorithm = GetOptionalTlsPropertyOrDefault(() => _sslStream.CipherAlgorithm); + _tlsConnectionFeature.CipherStrength = GetOptionalTlsPropertyOrDefault(() => _sslStream.CipherStrength); + _tlsConnectionFeature.HashAlgorithm = GetOptionalTlsPropertyOrDefault(() => _sslStream.HashAlgorithm); + _tlsConnectionFeature.HashStrength = GetOptionalTlsPropertyOrDefault(() => _sslStream.HashStrength); + _tlsConnectionFeature.KeyExchangeAlgorithm = GetOptionalTlsPropertyOrDefault(() => _sslStream.KeyExchangeAlgorithm); + _tlsConnectionFeature.KeyExchangeStrength = GetOptionalTlsPropertyOrDefault(() => _sslStream.KeyExchangeStrength); +#if NET10_0_OR_GREATER +#pragma warning restore SYSLIB0058 +#endif + _tlsConnectionFeature.Protocol = _sslStream.SslProtocol; + } + + private static T? GetOptionalTlsProperty(Func accessor) + where T : struct + { + try + { + return accessor(); + } + catch (NotSupportedException) + { + return null; + } + } + + private static T GetOptionalTlsPropertyOrDefault(Func accessor) + where T : struct + { + try + { + return accessor(); + } + catch (NotSupportedException) + { + return default; + } + } + + /// + public override async ValueTask DisposeAsync() + { + await CloseAsync(null, CancellationToken.None).ConfigureAwait(false); + + // SslStream disposes _networkTransportStream since leaveInnerStreamOpen: false + await _sslStream.DisposeAsync().ConfigureAwait(false); + + // Dispose inner transport last + await _innerTransport.DisposeAsync().ConfigureAwait(false); + + await base.DisposeAsync().ConfigureAwait(false); + GC.SuppressFinalize(this); + } + + private static X509Certificate2? ConvertToX509Certificate2(X509Certificate? certificate) + { + return certificate switch + { + null => null, + X509Certificate2 certificate2 => certificate2, + _ => new X509Certificate2(certificate) + }; + } + + public override string ToString() => $"Tls({_innerTransport})"; + + [LoggerMessage(2, LogLevel.Warning, "Authentication timed out")] + private static partial void LogAuthenticationTimedOut(ILogger logger, Exception exception); + + [LoggerMessage(1, LogLevel.Warning, "Authentication failed")] + private static partial void LogAuthenticationFailed(ILogger logger, Exception exception); +} diff --git a/src/Orleans.Core/Networking/Transport/Security/TlsMessageTransportConnector.cs b/src/Orleans.Core/Networking/Transport/Security/TlsMessageTransportConnector.cs new file mode 100644 index 00000000000..dabdbd3b55e --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/TlsMessageTransportConnector.cs @@ -0,0 +1,58 @@ +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Orleans.Connections.Transport.Security; + +/// +/// Message transport factory which configures transports for TLS. +/// +internal sealed partial class TlsMessageTransportConnector( + MessageTransportConnector innerTransportFactory, + IOptionsMonitor tlsOptions, + ILoggerFactory loggerFactory) : MessageTransportConnector +{ + private readonly MessageTransportConnector _innerConnector = innerTransportFactory; + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private readonly IOptionsMonitor _tlsOptions = tlsOptions; + + /// + public override IFeatureCollection Features => _innerConnector.Features; + + /// + public override bool IsValid => _innerConnector.IsValid; + + /// + public override async ValueTask CreateAsync(EndPoint endPoint, CancellationToken cancellationToken = default) + { + var innerTransport = await _innerConnector.CreateAsync(endPoint, cancellationToken); + try + { + var tlsOptions = _tlsOptions.CurrentValue; + var transport = new ClientTlsMessageTransport(innerTransport, tlsOptions, _logger); + transport.Start(); + return transport; + } + catch + { + try + { + await innerTransport.DisposeAsync().ConfigureAwait(false); + } + catch (Exception disposeException) + { + LogInnerTransportDisposalFailure(_logger, disposeException); + } + + throw; + } + } + + /// + public override ValueTask DisposeAsync() => _innerConnector.DisposeAsync(); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Exception disposing inner transport after TLS transport creation failed.")] + private static partial void LogInnerTransportDisposalFailure(ILogger logger, Exception exception); +} diff --git a/src/Orleans.Core/Networking/Transport/Security/TlsMessageTransportListener.cs b/src/Orleans.Core/Networking/Transport/Security/TlsMessageTransportListener.cs new file mode 100644 index 00000000000..03ae04361e0 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/TlsMessageTransportListener.cs @@ -0,0 +1,88 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Orleans.Connections.Transport.Security; + +/// +/// Message transport listener which configures transports for TLS. +/// +internal sealed partial class TlsMessageTransportListener( + MessageTransportListener innerListener, + IOptionsMonitor tlsOptions, + ILoggerFactory loggerFactory) : MessageTransportListener +{ + private readonly IOptionsMonitor _tlsOptions = tlsOptions; + private readonly MessageTransportListener _innerListener = innerListener; + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + /// + public override IFeatureCollection Features => _innerListener.Features; + + /// + public override bool IsValid => _innerListener.IsValid; + + /// + public override string ListenerName => _innerListener.ListenerName; + + /// + public override async ValueTask AcceptAsync(CancellationToken cancellationToken = default) + { + while (true) + { + var innerTransport = await _innerListener.AcceptAsync(cancellationToken).ConfigureAwait(false); + if (innerTransport is null) + { + return null; + } + + try + { + var transport = new ServerTlsMessageTransport(innerTransport, _tlsOptions.Get(ListenerName), _logger); + transport.Start(); + return transport; + } + catch (Exception exception) + { + try + { + await innerTransport.DisposeAsync().ConfigureAwait(false); + } + catch (Exception disposeException) + { + LogInnerTransportDisposalFailure(_logger, disposeException); + } + + if (cancellationToken.IsCancellationRequested || !_innerListener.IsValid) + { + return null; + } + + LogTlsTransportCreationFailure(_logger, exception); + } + } + } + + /// + public override async ValueTask BindAsync(CancellationToken cancellationToken = default) + { + await _innerListener.BindAsync(cancellationToken); + } + + /// + public override ValueTask UnbindAsync(CancellationToken cancellationToken = default) => _innerListener.UnbindAsync(cancellationToken); + + /// + public override async ValueTask DisposeAsync() + { + await _innerListener.DisposeAsync(); + await base.DisposeAsync(); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Exception disposing inner transport after TLS transport creation failed.")] + private static partial void LogInnerTransportDisposalFailure(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Rejected a connection because TLS transport creation failed.")] + private static partial void LogTlsTransportCreationFailure(ILogger logger, Exception exception); +} diff --git a/src/Orleans.Core/Networking/Transport/Security/TlsOptions.cs b/src/Orleans.Core/Networking/Transport/Security/TlsOptions.cs new file mode 100644 index 00000000000..e3eb3754d3f --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/TlsOptions.cs @@ -0,0 +1,134 @@ +using System; +using System.Buffers; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Threading; + +namespace Orleans.Connections.Transport.Security; + +public delegate bool RemoteCertificateValidator(X509Certificate2 certificate, X509Chain? chain, SslPolicyErrors policyErrors); + +/// +/// Settings for how TLS connections are handled. +/// +public class TlsOptions +{ + private static readonly TimeSpan MaximumFiniteHandshakeTimeout = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + private TimeSpan _handshakeTimeout = TimeSpan.FromSeconds(10); + + /// + /// + /// Specifies the local certificate used to authenticate TLS connections. This is ignored on server if is set. + /// + /// + /// To omit client authentication set to null on client and set to or on server. + /// + /// + /// If the certificate has an Extended Key Usage extension, the usages must include Server Authentication (OID 1.3.6.1.5.5.7.3.1) for server and Client Authentication (OID 1.3.6.1.5.5.7.3.2) for client. + /// + /// + public X509Certificate2? LocalCertificate { get; set; } + + /// + /// + /// A callback that will be invoked to dynamically select a local server certificate. This is higher priority than LocalCertificate. + /// If SNI is not available then the name parameter will be null. + /// + /// + /// If the certificate has an Extended Key Usage extension, the usages must include Server Authentication (OID 1.3.6.1.5.5.7.3.1). + /// + /// + public Func? LocalServerCertificateSelector { get; set; } + + /// + /// + /// A callback that will be invoked to dynamically select a local client certificate. This is higher priority than LocalCertificate. + /// Returning omits the client certificate when + /// is or . + /// When the mode is , a null result fails authentication. + /// + /// + /// If the certificate has an Extended Key Usage extension, the usages must include Client Authentication (OID 1.3.6.1.5.5.7.3.2). + /// + /// + public Func? LocalClientCertificateSelector { get; set; } + + /// + /// Specifies the remote endpoint certificate requirements for a TLS connection. Defaults to . + /// + public RemoteCertificateMode RemoteCertificateMode { get; set; } = RemoteCertificateMode.RequireCertificate; + + /// + /// Controls selection of the local client certificate when Orleans initiates a TLS connection. + /// Defaults to . + /// + public RemoteCertificateMode ClientCertificateMode { get; set; } = RemoteCertificateMode.AllowCertificate; + + /// + /// Specifies a callback for additional remote certificate validation that will be invoked during authentication. This will be ignored + /// if is called after this callback is set. + /// + public RemoteCertificateValidator? RemoteCertificateValidation { get; set; } + + /// + /// Specifies allowable SSL protocols. Defaults to and . + /// + public SslProtocols SslProtocols { get; set; } = SslProtocols.Tls13 | SslProtocols.Tls12; + + /// + /// Specifies whether the certificate revocation list is checked during authentication. + /// + public bool CheckCertificateRevocation { get; set; } + + /// + /// Overrides the current callback and allows any client certificate. + /// + public void AllowAnyRemoteCertificate() + { + RemoteCertificateValidation = (_, __, ___) => true; + } + + /// + /// Provides direct configuration of the on a per-connection basis. + /// This is called after all of the other settings have already been applied. + /// + public Action? OnAuthenticateAsServer { get; set; } + + /// + /// Provides direct configuration of the on a per-connection basis. + /// This is called after all of the other settings have already been applied. + /// + public Action? OnAuthenticateAsClient { get; set; } + + /// + /// Gets or sets the memory pool. + /// + public MemoryPool MemoryPool { get; set; } = MemoryPool.Shared; + + /// + /// Specifies the maximum amount of time allowed for the TLS/SSL handshake. + /// This must be positive and no greater than 4,294,967,294 milliseconds (approximately 49.7 days), + /// or to disable the timeout. + /// + public TimeSpan HandshakeTimeout + { + get => _handshakeTimeout; + set + { + if ((value <= TimeSpan.Zero && value != Timeout.InfiniteTimeSpan) || value > MaximumFiniteHandshakeTimeout) + { + throw new ArgumentOutOfRangeException( + nameof(value), + $"{nameof(HandshakeTimeout)} must be positive and no greater than {MaximumFiniteHandshakeTimeout}, or {nameof(Timeout)}.{nameof(Timeout.InfiniteTimeSpan)}"); + } + + _handshakeTimeout = value; + } + } + + internal CancellationTokenSource CreateHandshakeCancellationTokenSource() => + _handshakeTimeout == Timeout.InfiniteTimeSpan + ? new CancellationTokenSource() + : new CancellationTokenSource(_handshakeTimeout); +} diff --git a/src/Orleans.Core/Networking/Transport/Security/TlsServerAuthenticationOptions.cs b/src/Orleans.Core/Networking/Transport/Security/TlsServerAuthenticationOptions.cs new file mode 100644 index 00000000000..73b76773a21 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Security/TlsServerAuthenticationOptions.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; + +namespace Orleans.Connections.Transport.Security; + +/// +/// Selects the local certificate presented during a TLS server handshake. +/// +/// The performing the handshake. +/// The server name supplied by the client, when available. +/// The certificate to present, or when a certificate cannot be selected. +public delegate X509Certificate? ServerCertificateSelectionCallback(object sender, string? hostName); + +/// +/// Configures authentication for an Orleans TLS server handshake. +/// +public class TlsServerAuthenticationOptions +{ + internal SslServerAuthenticationOptions Value { get; } = new SslServerAuthenticationOptions + { + ApplicationProtocols = new List + { + new SslApplicationProtocol("Orleans1") + } + }; + + /// + /// Gets or sets the certificate presented by the server. + /// + public X509Certificate? ServerCertificate + { + get => Value.ServerCertificate; + set => Value.ServerCertificate = value; + } + + /// + /// Gets or sets the callback which selects the server certificate using the client's server name indication. + /// + public ServerCertificateSelectionCallback? ServerCertificateSelectionCallback + { + get => Value.ServerCertificateSelectionCallback is null ? null : new ServerCertificateSelectionCallback(Value.ServerCertificateSelectionCallback); + set => Value.ServerCertificateSelectionCallback = value is null ? null : new System.Net.Security.ServerCertificateSelectionCallback(value!); + } + + /// + /// Gets or sets a value indicating whether the server requests a client certificate. + /// + public bool ClientCertificateRequired + { + get => Value.ClientCertificateRequired; + set => Value.ClientCertificateRequired = value; + } + + /// + /// Gets or sets the TLS protocol versions allowed for the handshake. + /// + public SslProtocols EnabledSslProtocols + { + get => Value.EnabledSslProtocols; + set => Value.EnabledSslProtocols = value; + } + + /// + /// Gets or sets how the remote certificate's revocation status is checked. + /// + public X509RevocationMode CertificateRevocationCheckMode + { + get => Value.CertificateRevocationCheckMode; + set => Value.CertificateRevocationCheckMode = value; + } + + /// + /// Gets the underlying for additional handshake configuration. + /// + public object SslServerAuthenticationOptions => Value; +} diff --git a/src/Orleans.Core/Networking/Transport/Sockets/AddressInUseException.cs b/src/Orleans.Core/Networking/Transport/Sockets/AddressInUseException.cs new file mode 100644 index 00000000000..6d7cfca7512 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Sockets/AddressInUseException.cs @@ -0,0 +1,19 @@ +using System; + +namespace Orleans.Connections.Transport.Sockets; + +[Serializable] +internal sealed class AddressInUseException : Exception +{ + public AddressInUseException() + { + } + + public AddressInUseException(string? message) : base(message) + { + } + + public AddressInUseException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/src/Orleans.Core/Networking/Transport/Sockets/BufferExtensions.cs b/src/Orleans.Core/Networking/Transport/Sockets/BufferExtensions.cs new file mode 100644 index 00000000000..e028a39d7df --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Sockets/BufferExtensions.cs @@ -0,0 +1,20 @@ +using System; +using System.Runtime.InteropServices; + +namespace Orleans.Connections.Transport.Sockets; + +internal static class BufferExtensions +{ + public static ArraySegment GetArray(this Memory memory) => ((ReadOnlyMemory)memory).GetArray(); + + public static ArraySegment GetArray(this ReadOnlyMemory memory) + { + if (!MemoryMarshal.TryGetArray(memory, out var result)) + { + ThrowInvalid(); + } + + return result; + void ThrowInvalid() => throw new InvalidOperationException("Buffer backed by array was expected"); + } +} diff --git a/src/Orleans.Core/Networking/Transport/Sockets/SocketAwaitableEventArgs.cs b/src/Orleans.Core/Networking/Transport/Sockets/SocketAwaitableEventArgs.cs new file mode 100644 index 00000000000..11481c619a8 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Sockets/SocketAwaitableEventArgs.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks.Sources; + +namespace Orleans.Connections.Transport.Sockets; + +// A slimmed down version of https://github.com/dotnet/runtime/blob/82ca681cbac89d813a3ce397e0c665e6c051ed67/src/libraries/System.Net.Sockets/src/System/Net/Sockets/Socket.Tasks.cs#L798 that +// 1. Doesn't support any custom scheduling other than the PipeScheduler (no sync context, no task scheduler) +// 2. Doesn't do ValueTask validation using the token +// 3. Doesn't support usage outside of async/await (doesn't try to capture and restore the execution context) +// 4. Doesn't use cancellation tokens +internal class SocketAwaitableEventArgs : SocketAsyncEventArgs, IValueTaskSource +{ + private static readonly Action ContinuationCompleted = _ => { }; + private Action? _continuation; + + public SocketAwaitableEventArgs() + : base(unsafeSuppressExecutionContextFlow: true) + { + } + + public bool IsCompleted { get; private set; } + + public Exception? Error => CreateException(SocketError); + + [MemberNotNullWhen(true, nameof(Error))] + public bool HasError => SocketError != SocketError.Success; + + protected override void OnCompleted(SocketAsyncEventArgs _) + { + IsCompleted = true; + var continuation = _continuation; + + if (continuation != null || (continuation = Interlocked.CompareExchange(ref _continuation, ContinuationCompleted, null)) != null) + { + var state = UserToken; + UserToken = null; + _continuation = ContinuationCompleted; // in case someone's polling IsCompleted + + // Execute the continuation inline. + continuation(state); + } + } + + public void GetResult(short token) + { + _continuation = null; + IsCompleted = false; + if (HasError) ThrowError(); + + void ThrowError() => throw Error; + } + + protected static SocketException? CreateException(SocketError e) + { + if (e is SocketError.Success) return null; + return new SocketException((int)e); + } + + public ValueTaskSourceStatus GetStatus(short token) + { + return !ReferenceEquals(_continuation, ContinuationCompleted) ? ValueTaskSourceStatus.Pending : + SocketError == SocketError.Success ? ValueTaskSourceStatus.Succeeded : + ValueTaskSourceStatus.Faulted; + } + + public void OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) + { + UserToken = state; + var prevContinuation = Interlocked.CompareExchange(ref _continuation, continuation, null); + if (ReferenceEquals(prevContinuation, ContinuationCompleted)) + { + UserToken = null; + + // Execute the continuation inline. + continuation(state); + } + } +} diff --git a/src/Orleans.Core/Networking/Transport/Sockets/SocketExtensions.cs b/src/Orleans.Core/Networking/Transport/Sockets/SocketExtensions.cs new file mode 100644 index 00000000000..76112d695da --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Sockets/SocketExtensions.cs @@ -0,0 +1,84 @@ +using System; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + +namespace Orleans.Connections.Transport.Sockets; + +internal static class SocketExtensions +{ + private const int SIO_LOOPBACK_FAST_PATH = -1744830448; + private static readonly byte[] Enabled = BitConverter.GetBytes(1); + + internal static void ConfigureKeepAlive(this Socket socket, TcpMessageTransportOptions options, ILogger logger) => + ConfigureKeepAlive(options, logger, socket.SetSocketOption); + + internal static void ConfigureKeepAlive( + TcpMessageTransportOptions options, + ILogger logger, + Action setSocketOption) + { + if (!TrySet(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, options.KeepAlive ? 1 : 0) || !options.KeepAlive) + { + return; + } + + TrySet(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, options.KeepAliveTimeSeconds); + TrySet(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, options.KeepAliveIntervalSeconds); + TrySet(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, options.KeepAliveRetryCount); + + bool TrySet(SocketOptionLevel level, SocketOptionName option, int value) + { + try + { + setSocketOption(level, option, value); + return true; + } + catch (SocketException exception) when (exception.SocketErrorCode is + SocketError.ProtocolOption or SocketError.OperationNotSupported or + SocketError.ProtocolNotSupported or SocketError.SocketNotSupported) + { + SocketsLog.UnsupportedKeepAliveOption(logger, exception, option); + return false; + } + catch (PlatformNotSupportedException exception) + { + SocketsLog.UnsupportedKeepAliveOption(logger, exception, option); + return false; + } + } + } + + /// + /// Enables TCP Loopback Fast Path on a socket. + /// See https://blogs.technet.microsoft.com/wincat/2012/12/05/fast-tcp-loopback-performance-and-low-latency-with-windows-server-2012-tcp-loopback-fast-path/ + /// for more information. + /// + /// The socket for which FastPath should be enabled. + public static void EnableFastPath(this Socket socket, bool noDelay = true) + { + if (noDelay) + { + try { socket.NoDelay = true; } catch { } + } + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + try + { + // Win8/Server2012+ only + var osVersion = Environment.OSVersion.Version; + if (osVersion.Major > 6 || osVersion.Major == 6 && osVersion.Minor >= 2) + { + socket.IOControl(SIO_LOOPBACK_FAST_PATH, Enabled, null); + } + } + catch + { + // If the operating system version on this machine did + // not support SIO_LOOPBACK_FAST_PATH (i.e. version + // prior to Windows 8 / Windows Server 2012), handle the exception + } + } +} diff --git a/src/Orleans.Core/Networking/Transport/Sockets/SocketMessageTransport.cs b/src/Orleans.Core/Networking/Transport/Sockets/SocketMessageTransport.cs new file mode 100644 index 00000000000..c9f11129a83 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Sockets/SocketMessageTransport.cs @@ -0,0 +1,903 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using Orleans.Runtime.Internal; +using System.Net; +using Orleans.Runtime; +using System.Runtime.CompilerServices; +using Orleans.Serialization.Buffers; +using Orleans.Connections.Transport; +using Orleans.Runtime.Utilities; + +namespace Orleans.Connections.Transport.Sockets; + +internal sealed partial class SocketMessageTransport : MessageTransportBase +{ + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static readonly bool IsMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + + private readonly SocketSender _socketSender = new(); + private readonly SocketReceiver _socketReceiver = new(); + private readonly Socket _socket; + private readonly Queue _readRequests = new(); + private readonly SingleWaiterAutoResetEvent _readSignal = new() { RunContinuationsAsynchronously = false }; + private readonly SingleWaiterAutoResetEvent _writeSignal = new() { RunContinuationsAsynchronously = false }; + private readonly ILogger _logger; + private readonly CancellationTokenSource _connectionClosingCts = new(); + private readonly CancellationTokenSource _connectionClosedCts = new(); + private readonly object _shutdownLock = new(); + private readonly object _readsLock = new(); + private readonly string _remoteEndpointString; // For diagnostics only + private readonly string _localEndpointString; // For diagnostics only + private readonly StripedMpscBuffer _writeRequests = new( + stripeCount: 16, + bufferSize: 256); + private bool _readsCompleted; + private int _pendingWriteCount; + private int _writeEnqueuers; + private int _writesCompleted; + private Task? _processingTask; + private volatile bool _socketDisposed; + private volatile bool _socketShutdown; + private volatile Exception? _shutdownReason; + private int _disposeStarted; + + public SocketMessageTransport(Socket socket, ILogger logger) + { + _socket = socket; + _logger = logger; + + var remoteEndPoint = NormalizeEndpoint(_socket.RemoteEndPoint); + var localEndPoint = NormalizeEndpoint(_socket.LocalEndPoint); + + Features.Set(new ConnectionEndPointFeature + { + RemoteEndPoint = remoteEndPoint, + LocalEndPoint = localEndPoint, + }); + + _remoteEndpointString = remoteEndPoint?.ToString() ?? "null"; + _localEndpointString = localEndPoint?.ToString() ?? "null"; + } + + internal static async ValueTask CreateAndStartAsync(Socket socket, ILogger logger) + { + SocketMessageTransport? transport = null; + try + { + transport = new SocketMessageTransport(socket, logger); + transport.Start(); + return transport; + } + catch + { + if (transport is not null) + { + await transport.DisposeAsync().ConfigureAwait(false); + } + else + { + socket.Dispose(); + } + + throw; + } + } + + public override CancellationToken Closed => _connectionClosedCts.Token; + + public void Start() + { + using var _ = new ExecutionContextSuppressor(); + _processingTask = ProcessConnectionAsync(); + } + + private async Task ProcessConnectionAsync() + { + // Return immediately to the synchronous caller. + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + + try + { + // Spawn send and receive logic + var receiveTask = ProcessReads(); + var sendTask = ProcessWrites(); + + // Wait for both to complete + try + { + await receiveTask; + } + catch (Exception ex) + { + LogProcessReadsFailure(_logger, ex); + } + + try + { + await sendTask; + } + catch (Exception ex) + { + LogProcessWritesFailure(_logger, ex); + } + + _socketReceiver.Dispose(); + _socketSender.Dispose(); + } + catch (Exception ex) + { + _shutdownReason ??= ex; + LogProcessConnectionFailure(_logger, ex); + } + finally + { + Shutdown(); + + await _connectionClosingCts.CancelAsync().ConfigureAwait(false); + await _connectionClosedCts.CancelAsync().ConfigureAwait(false); + } + } + + private void Shutdown() + { + if (_socketDisposed) + { + return; + } + + lock (_shutdownLock) + { + try + { + if (_socketDisposed) + { + return; + } + + _socketDisposed = true; + + // shutdownReason should only be null if the output was completed gracefully, so no one should ever + // ever observe the nondescript ConnectionAbortedException except for connection middleware attempting + // to half close the connection which is currently unsupported. + _shutdownReason ??= new ConnectionAbortedException("The Socket transport's send loop completed gracefully."); + SocketsLog.ConnectionWriteFin(_logger, this, _shutdownReason.Message); + + // Only call Shutdown if we haven't already done so + if (!_socketShutdown) + { + _socketShutdown = true; + try + { + _socket.Shutdown(SocketShutdown.Both); + } + catch + { + // Ignore any errors from Socket.Shutdown() since we're tearing down the connection anyway. + } + } + + _socket.Dispose(); + } + catch (Exception exception) + { + SocketsLog.ConnectionShutdownError(_logger, this, exception); + } + } + } + + /// + /// Initiates a graceful shutdown of the socket to interrupt pending I/O operations. + /// This does not dispose the socket - that happens in . + /// + private void ShutdownSocket() + { + if (_socketShutdown || _socketDisposed) + { + return; + } + + lock (_shutdownLock) + { + if (_socketShutdown || _socketDisposed) + { + return; + } + + _socketShutdown = true; + + try + { + _socket.Shutdown(SocketShutdown.Both); + } + catch + { + // Ignore errors - socket may already be in error state or not connected + } + } + } + + public override bool EnqueueRead(ReadRequest request) + { + if (_connectionClosingCts.IsCancellationRequested) + { + return false; + } + + lock (_readsLock) + { + if (_readsCompleted) + { + return false; + } + + _readRequests.Enqueue(request); + } + + _readSignal.Signal(); + return true; + } + + public override bool EnqueueWrite(WriteRequest request) + { + if (_connectionClosingCts.IsCancellationRequested || Volatile.Read(ref _writesCompleted) != 0) + { + return false; + } + + Interlocked.Increment(ref _writeEnqueuers); + if (_connectionClosingCts.IsCancellationRequested || Volatile.Read(ref _writesCompleted) != 0) + { + Interlocked.Decrement(ref _writeEnqueuers); + return false; + } + + Interlocked.Increment(ref _pendingWriteCount); + var spinner = new SpinWait(); + while (_writeRequests.TryAdd(request) is not BufferStatus.Success) + { + if (_connectionClosingCts.IsCancellationRequested || Volatile.Read(ref _writesCompleted) != 0) + { + Interlocked.Decrement(ref _pendingWriteCount); + Interlocked.Decrement(ref _writeEnqueuers); + return false; + } + + _writeSignal.Signal(); + spinner.SpinOnce(); + } + + Interlocked.Decrement(ref _writeEnqueuers); + _writeSignal.Signal(); + return true; + } + + public override async ValueTask CloseAsync(Exception? closeReason, CancellationToken cancellationToken = default) + { + _shutdownReason ??= closeReason; + + // Early exit if already closed + if (_connectionClosedCts.IsCancellationRequested) + { + return; + } + + // Signal loops to stop + await _connectionClosingCts.CancelAsync().ConfigureAwait(false); + _readSignal.Signal(); + _writeSignal.Signal(); + + // If no processing task, just dispose the socket directly + if (_processingTask is null) + { + Shutdown(); + await _connectionClosedCts.CancelAsync().ConfigureAwait(false); + return; + } + + // Shutdown the socket to interrupt any pending I/O operations. + // This will cause ReceiveAsync/SendAsync to complete with an error, + // allowing the processing loops to exit gracefully. + ShutdownSocket(); + + try + { + await _processingTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + Shutdown(); + } + } + + public override async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + { + return; + } + + try + { + // Ensure socket is shutdown and disposed, even if CloseAsync wasn't called or timed out + Shutdown(); + + // Signal that we're closing if not already done + await _connectionClosingCts.CancelAsync().ConfigureAwait(false); + _readSignal.Signal(); + _writeSignal.Signal(); + + if (_processingTask is { } processingTask) + { + await processingTask.ConfigureAwait(false); + } + else + { + _socketReceiver.Dispose(); + _socketSender.Dispose(); + await _connectionClosedCts.CancelAsync().ConfigureAwait(false); + } + + await base.DisposeAsync().ConfigureAwait(false); + } + finally + { + _connectionClosingCts.Dispose(); + _connectionClosedCts.Dispose(); + GC.SuppressFinalize(this); + } + } + + private async Task ProcessReads() + { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + var isGracefulTermination = false; + Exception? error = null; + ReadRequest? request = null; + using ArcBufferWriter bufferWriter = new(); + var reader = new ArcBufferReader(bufferWriter); + + // Note that socket APIs can generally only accept a maximum number of buffers. + // For example on Linux, the maximum is defined via IOV_MAX in and is typically 16. + // See https://www.man7.org/linux/man-pages/man0/limits.h.0p.html + // Here, we choose 8 as the maximum number of buffers which CoreCLR will stackalloc on *nix, + // see: https://github.com/dotnet/runtime/blob/0cf461b302f58c7add3f6dc405873fb2212b513f/src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketPal.Unix.cs#L24 + List> networkBuffers = new(capacity: 8); + + try + { + // Loop until termination. + while (!_connectionClosingCts.IsCancellationRequested) + { + // Handle each request. + while (TryDequeue(out request)) + { + // Process the request to completion. + while (true) + { + if (request.OnRead(reader)) + { + // This request is complete, move on to the next one. + break; + } + + bufferWriter.ReplenishBuffers(networkBuffers); + Debug.Assert(networkBuffers.Count == networkBuffers.Capacity); + await _socketReceiver.ReceiveAsync(_socket, networkBuffers).ConfigureAwait(false); + + if (_socketReceiver.HasError) + { + error = _socketReceiver.Error; + isGracefulTermination = HandleReadError(ref error); + goto exit; + } + + var transferred = _socketReceiver.BytesTransferred; + + AdvanceBufferList(networkBuffers, transferred); + bufferWriter.AdvanceWriter(transferred); + + if (transferred == 0) + { + // FIN + SocketsLog.ConnectionReadFin(_logger, this); + isGracefulTermination = true; + goto exit; + } + } + + request = null; + } + + await _readSignal.WaitAsync().ConfigureAwait(false); + } + + isGracefulTermination = true; +exit: +/* no op */ + ; + } + catch (Exception exception) + { + if (_connectionClosingCts.IsCancellationRequested) + { + isGracefulTermination = true; + } + else + { + error = exception; + isGracefulTermination = HandleReadError(ref error); + } + } + finally + { + _shutdownReason ??= error; + Shutdown(); + await _connectionClosingCts.CancelAsync().ConfigureAwait(false); + + if (isGracefulTermination) + { + request?.OnCanceled(); + } + else + { + Debug.Assert(error is not null); + request?.OnError(error); + } + + _writeSignal.Signal(); + + lock (_readsLock) + { + _readsCompleted = true; + while (_readRequests.TryDequeue(out request)) + { + if (isGracefulTermination) + { + request.OnCanceled(); + } + else + { + Debug.Assert(error is not null); + request.OnError(error); + } + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + bool TryDequeue([NotNullWhen(true)] out ReadRequest? request) + { + lock (_readsLock) + { + return _readRequests.TryDequeue(out request); + } + } + + } + + private bool HandleReadError(ref Exception? error) + { + if (_socketReceiver.HasError) + { + error = _socketReceiver.Error; + } + + // If we initiated shutdown, treat any error as graceful termination + if (_socketDisposed || _socketShutdown) + { + error = null; + return true; + } + + if (error is ObjectDisposedException) + { + // This is unexpected if the socket hasn't been disposed yet. + SocketsLog.ConnectionError(_logger, this, error); + } + else if (IsConnectionResetError(_socketReceiver.SocketError)) + { + // This could be ignored if _shutdownReason is already set. + error = null; + + // There's still a small chance that both DoReceive() and DoSend() can log the same connection reset. + // Both logs will have the same ConnectionId. I don't think it's worthwhile to lock just to avoid this. + SocketsLog.ConnectionReset(_logger, this); + } + else if (IsConnectionAbortError(_socketReceiver.SocketError)) + { + // This exception should always be ignored because _shutdownReason should be set. + error = null; + } + else if (error is { }) + { + // This is unexpected. + SocketsLog.ConnectionError(_logger, this, error); + } + else + { + error = null; + } + + return error is null; + } + + private async Task ProcessWrites() + { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + + const int SmallRequestBufferLimit = 32; + const int LargeRequestBufferLimit = 64; + const int CoalescingBufferSize = 64 * 1024; + Exception? error = null; + Queue requests = new(); + WriteRequest[] requestBuffer = new WriteRequest[256]; + List> buffers = new(capacity: LargeRequestBufferLimit); + List<(WriteRequest, ArcBuffer)> processingRequests = new(capacity: LargeRequestBufferLimit); + var coalescingBuffer = ArrayPool.Shared.Rent(CoalescingBufferSize); + ArcBuffer.ArraySegmentEnumerator enumerator = default; + var bufferLimit = SmallRequestBufferLimit; + + try + { + // Loop until termination. + while (!_connectionClosingCts.IsCancellationRequested) + { + while (buffers.Count < bufferLimit) + { + // Try to consume a buffer from the current enumerator. + if (enumerator.MoveNext()) + { + Debug.Assert(enumerator.Current.Count > 0); + buffers.Add(enumerator.Current); + } + else + { +DequeueRequest: +// Try to get the next request and consume that. + if (requests.TryDequeue(out var request)) + { + if (request.Buffers.Length == 0) + { + request.SetResult(); + goto DequeueRequest; + } + + if (request.HasLargeMessages) + { + bufferLimit = LargeRequestBufferLimit; + } + + // Start enumerating the next request. + var slice = request.Buffers.ConsumeSlice(request.Buffers.Length); + processingRequests.Add((request, slice)); + enumerator = slice.ArraySegments; + } + else if (buffers.Count == 0) + { +RefreshRequestQueue: + if (_connectionClosingCts.IsCancellationRequested) + { + break; + } + + // Check for pending messages before waiting. + RefreshRequestQueue(requests, requestBuffer); + + // Wait for more requests. + if (requests.Count == 0) + { + await _writeSignal.WaitAsync().ConfigureAwait(false); + goto RefreshRequestQueue; + } + + goto DequeueRequest; + } + else + { + // Send the current buffers. + enumerator = default; + break; + } + } + } + + // If there are no buffers to send, continue to check for more requests or exit + if (buffers.Count == 0) + { + continue; + } + + var totalBytes = 0; + foreach (var buffer in buffers) + { + totalBytes += buffer.Count; + } + + if (buffers.Count == 1 + && processingRequests.Count == 1 + && requests.Count == 0 + && Volatile.Read(ref _pendingWriteCount) == 0) + { + var buffer = buffers[0]; + var remaining = new ReadOnlyMemory(buffer.Array!, buffer.Offset, buffer.Count); + while (!remaining.IsEmpty) + { + await _socketSender.SendAsync(_socket, remaining).ConfigureAwait(false); + if (_socketSender.HasError) + { + error = GetSendAsyncError(); + break; + } + + var transferred = _socketSender.BytesTransferred; + if (transferred == 0) + { + error = new ConnectionClosedException("The socket closed before all queued data was sent."); + break; + } + + remaining = remaining[transferred..]; + } + + if (error is null) + { + buffers.Clear(); + } + } + else if (bufferLimit == SmallRequestBufferLimit + && buffers.Count > 1 + && totalBytes <= coalescingBuffer.Length) + { + var offset = 0; + foreach (var buffer in buffers) + { + buffer.AsSpan().CopyTo(coalescingBuffer.AsSpan(offset)); + offset += buffer.Count; + } + + var remaining = new ReadOnlyMemory(coalescingBuffer, 0, totalBytes); + while (!remaining.IsEmpty) + { + await _socketSender.SendAsync(_socket, remaining).ConfigureAwait(false); + if (_socketSender.HasError) + { + error = GetSendAsyncError(); + break; + } + + var transferred = _socketSender.BytesTransferred; + if (transferred == 0) + { + error = new ConnectionClosedException("The socket closed before all queued data was sent."); + break; + } + + remaining = remaining[transferred..]; + } + + if (error is null) + { + buffers.Clear(); + } + } + else + { + while (buffers.Count > 0) + { + await _socketSender.SendAsync(_socket, buffers).ConfigureAwait(false); + if (_socketSender.HasError) + { + error = GetSendAsyncError(); + break; + } + + var transferred = _socketSender.BytesTransferred; + if (transferred == 0) + { + error = new ConnectionClosedException("The socket closed before all queued data was sent."); + break; + } + + AdvanceBufferList(buffers, transferred); + } + } + + if (error is not null) + { + break; + } + + bufferLimit = SmallRequestBufferLimit; + + // Signal that the requests are completed + for (var i = 0; i < processingRequests.Count - 1; i++) + { + var (request, slice) = processingRequests[i]; + request.SetResult(); + slice.Dispose(); + } + + var last = processingRequests[^1]; + processingRequests.Clear(); + + // Avoid disposing the last item unless enumeration has completed. + if (enumerator.IsCompleted) + { + var (request, slice) = last; + request.SetResult(); + slice.Dispose(); + } + else + { + processingRequests.Add(last); + } + } + } + catch (Exception ex) + { + error = ex; + if (!_socketDisposed) + { + SocketsLog.ConnectionError(_logger, this, error); + } + } + finally + { + _shutdownReason ??= error; + Shutdown(); + await _connectionClosingCts.CancelAsync().ConfigureAwait(false); + _readSignal.Signal(); + + var requestError = _shutdownReason ?? new ConnectionClosedException(); + foreach (var (request, slice) in processingRequests) + { + request.SetException(requestError); + slice.Dispose(); + } + + Volatile.Write(ref _writesCompleted, 1); + var spinner = new SpinWait(); + while (Volatile.Read(ref _writeEnqueuers) != 0) + { + spinner.SpinOnce(); + } + + // Drain requests. + int drained; + do + { + drained = RefreshRequestQueue(requests, requestBuffer); + while (requests.TryDequeue(out var request)) + { + request.SetException(requestError); + } + } + while (drained > 0); + + ArrayPool.Shared.Return(coalescingBuffer); + } + + int RefreshRequestQueue(Queue queue, WriteRequest[] buffer) + { + var count = _writeRequests.DrainTo(buffer); + Interlocked.Add(ref _pendingWriteCount, -count); + for (var i = 0; i < count; i++) + { + queue.Enqueue(buffer[i]); + buffer[i] = null!; + } + + return count; + } + + } + + private static void AdvanceBufferList(List> buffers, int bytesTransferred) + { + while (bytesTransferred > 0) + { + Debug.Assert(buffers.Count > 0); + var current = buffers[0]; + if (bytesTransferred >= current.Count) + { + bytesTransferred -= current.Count; + buffers.RemoveAt(0); + } + else + { + buffers[0] = new(current.Array!, current.Offset + bytesTransferred, current.Count - bytesTransferred); + bytesTransferred = 0; + } + } + } + + private Exception GetSendAsyncError() + { + Exception error; + if (IsConnectionResetError(_socketSender.SocketError)) + { + // This could be ignored if _shutdownReason is already set. + var ex = _socketSender.Error!; + error = new ConnectionResetException(ex.Message, ex); + + // There's still a small chance that both DoReceive() and DoSend() can log the same connection reset. + // Both logs will have the same ConnectionId. I don't think it's worthwhile to lock just to avoid this. + if (!_socketDisposed) + { + SocketsLog.ConnectionReset(_logger, this); + } + } + else if (IsConnectionAbortError(_socketSender.SocketError)) + { + // This exception should always be ignored because _shutdownReason should be set. + error = _socketSender.Error!; + + if (!_socketDisposed) + { + // This is unexpected if the socket hasn't been disposed yet. + SocketsLog.ConnectionError(_logger, this, error); + } + } + else + { + // This is unexpected. + error = _socketSender.Error!; + if (!_socketDisposed) + { + SocketsLog.ConnectionError(_logger, this, error); + } + } + + return error; + } + + private static bool IsConnectionResetError(SocketError errorCode) + { + // A connection reset can be reported as SocketError.ConnectionAborted on Windows. + // ProtocolType can be removed once https://github.com/dotnet/corefx/issues/31927 is fixed. + return errorCode == SocketError.ConnectionReset || + errorCode == SocketError.Shutdown || + errorCode == SocketError.ConnectionAborted && IsWindows || + errorCode == SocketError.ProtocolType && IsMacOS; + } + + private static bool IsConnectionAbortError(SocketError errorCode) + { + // Calling Dispose after ReceiveAsync can cause an "InvalidArgument" error on *nix. + return errorCode == SocketError.OperationAborted || + errorCode == SocketError.Interrupted || + errorCode == SocketError.InvalidArgument && !IsWindows; + } + + private static EndPoint? NormalizeEndpoint(EndPoint? endpoint) + { + if (endpoint is not IPEndPoint ep) return endpoint; + + // Normalize endpoints + if (ep.Address.IsIPv4MappedToIPv6) + { + return new IPEndPoint(ep.Address.MapToIPv4(), ep.Port); + } + + return ep; + } + + public override string ToString() => $"Socket(Remote: {_remoteEndpointString}, Local: {_localEndpointString})"; + + [LoggerMessage(0, LogLevel.Error, $"Unexpected exception in {nameof(SocketMessageTransport)}.{nameof(ProcessReads)}.")] + private static partial void LogProcessReadsFailure(ILogger logger, Exception exception); + + [LoggerMessage(0, LogLevel.Error, $"Unexpected exception in {nameof(SocketMessageTransport)}.{nameof(ProcessWrites)}.")] + private static partial void LogProcessWritesFailure(ILogger logger, Exception exception); + + [LoggerMessage(0, LogLevel.Error, $"Unexpected exception in {nameof(SocketMessageTransport)}.{nameof(ProcessConnectionAsync)}.")] + private static partial void LogProcessConnectionFailure(ILogger logger, Exception exception); +} diff --git a/src/Orleans.Core/Networking/Transport/Sockets/SocketOperationResult.cs b/src/Orleans.Core/Networking/Transport/Sockets/SocketOperationResult.cs new file mode 100644 index 00000000000..f25416e2c48 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Sockets/SocketOperationResult.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Net.Sockets; + +namespace Orleans.Connections.Transport.Sockets; + +internal readonly struct SocketOperationResult +{ + public readonly SocketException? SocketError; + + public readonly int BytesTransferred; + + [MemberNotNullWhen(true, nameof(SocketError))] + public readonly bool HasError => SocketError != null; + + public SocketOperationResult(int bytesTransferred) + { + SocketError = null; + BytesTransferred = bytesTransferred; + } + + public SocketOperationResult(SocketException exception) + { + SocketError = exception; + BytesTransferred = 0; + } +} diff --git a/src/Orleans.Core/Networking/Transport/Sockets/SocketReceiver.cs b/src/Orleans.Core/Networking/Transport/Sockets/SocketReceiver.cs new file mode 100644 index 00000000000..aeb4d8b8f8c --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Sockets/SocketReceiver.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Net.Sockets; +using System.Threading.Tasks; + +namespace Orleans.Connections.Transport.Sockets; + +internal sealed class SocketReceiver : SocketAwaitableEventArgs +{ + public SocketReceiver() + { + } + + public ValueTask ReceiveAsync(Socket socket, List> buffers) + { + BufferList = buffers; + + if (socket.ReceiveAsync(this)) + { + return new ValueTask(this, 0); + } + + var error = Error; + return error is not null ? ValueTask.FromException(error) : default; + } +} diff --git a/src/Orleans.Core/Networking/Transport/Sockets/SocketSender.cs b/src/Orleans.Core/Networking/Transport/Sockets/SocketSender.cs new file mode 100644 index 00000000000..b6ce6163185 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Sockets/SocketSender.cs @@ -0,0 +1,117 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace Orleans.Connections.Transport.Sockets; + +internal sealed class SocketSender : SocketAwaitableEventArgs +{ + private List>? _bufferList; + + public SocketSender() + { + } + + public ValueTask SendAsync(Socket socket, in ReadOnlySequence buffers) + { + if (buffers.IsSingleSegment) + { + return SendAsync(socket, buffers.First); + } + + SetBufferList(buffers); + + if (socket.SendAsync(this)) + { + return new ValueTask(this, 0); + } + + var error = Error; + return error is not null ? ValueTask.FromException(error) : default; + } + + public ValueTask SendAsync(Socket socket, List> buffers) + { + if (!MemoryBuffer.IsEmpty) + { + SetBuffer(null, 0, 0); + } + + BufferList = buffers; + + if (socket.SendAsync(this)) + { + return new ValueTask(this, 0); + } + + var error = Error; + return error is not null ? ValueTask.FromException(error) : default; + } + + public void Reset() + { + // We clear the buffer and buffer list before we put it back into the pool + // it's a small performance hit but it removes the confusion when looking at dumps to see this still + // holds onto the buffer when it's back in the pool + if (BufferList != null) + { + BufferList = null; + + _bufferList?.Clear(); + } + else + { + SetBuffer(null, 0, 0); + } + } + + public ValueTask SendAsync(Socket socket, ReadOnlyMemory memory) + { + if (BufferList is not null) + { + BufferList = null; + } + + SetBuffer(MemoryMarshal.AsMemory(memory)); + + if (socket.SendAsync(this)) + { + return new ValueTask(this, 0); + } + + var error = Error; + return error is not null ? ValueTask.FromException(error) : default; + } + + private void SetBufferList(in ReadOnlySequence buffer) + { + Debug.Assert(!buffer.IsEmpty); + Debug.Assert(!buffer.IsSingleSegment); + + if (BufferList is not null) + { + BufferList = null; + } + else if (!MemoryBuffer.IsEmpty) + { + SetBuffer(null, 0, 0); + } + + _bufferList ??= new List>(); + _bufferList.Clear(); + foreach (var b in buffer) + { + _bufferList.Add(b.GetArray()); + } + + // The act of setting this list, sets the buffers in the internal buffer list + BufferList = _bufferList; + } +} diff --git a/src/Orleans.Core/Networking/Transport/Sockets/SocketsLog.cs b/src/Orleans.Core/Networking/Transport/Sockets/SocketsLog.cs new file mode 100644 index 00000000000..205ca5935fd --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Sockets/SocketsLog.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Net.Sockets; +using Microsoft.Extensions.Logging; + +namespace Orleans.Connections.Transport.Sockets; + +internal static partial class SocketsLog +{ + [LoggerMessage(21, LogLevel.Warning, "TCP keep-alive option {Option} is unsupported on this platform; retaining the supported socket settings.", EventName = "UnsupportedKeepAliveOption")] + internal static partial void UnsupportedKeepAliveOption(ILogger logger, Exception exception, SocketOptionName option); + + // Reserved: Event ID 3, EventName = ConnectionRead + + [LoggerMessage(6, LogLevel.Debug, @"Connection ""{Connection}"" received FIN.", EventName = "ConnectionReadFin", SkipEnabledCheck = true)] + private static partial void ConnectionReadFinCore(ILogger logger, string connection); + + public static void ConnectionReadFin(ILogger logger, SocketMessageTransport connection) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ConnectionReadFinCore(logger, connection.ToString()); + } + } + + [LoggerMessage(7, LogLevel.Debug, @"Connection ""{Connection}"" sending FIN because: ""{Reason}""", EventName = "ConnectionWriteFin", SkipEnabledCheck = true)] + private static partial void ConnectionWriteFinCore(ILogger logger, string connection, string reason); + + public static void ConnectionWriteFin(ILogger logger, SocketMessageTransport connection, string reason) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ConnectionWriteFinCore(logger, connection.ToString(), reason); + } + } + + // Reserved: Event ID 11, EventName = ConnectionWrite + + // Reserved: Event ID 12, EventName = ConnectionWriteCallback + + [LoggerMessage(14, LogLevel.Debug, @"Connection ""{Connection}"" communication error.", EventName = "ConnectionError", SkipEnabledCheck = true)] + private static partial void ConnectionErrorCore(ILogger logger, string connection, Exception ex); + + public static void ConnectionError(ILogger logger, SocketMessageTransport connection, Exception ex) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ConnectionErrorCore(logger, connection.ToString(), ex); + } + } + + [LoggerMessage(19, LogLevel.Debug, @"Connection ""{Connection}"" reset.", EventName = "ConnectionReset", SkipEnabledCheck = true)] + public static partial void ConnectionReset(ILogger logger, string connection); + + public static void ConnectionReset(ILogger logger, SocketMessageTransport connection) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ConnectionReset(logger, connection.ToString()); + } + } + + [LoggerMessage(4, LogLevel.Debug, @"Connection ""{Connection}"" paused.", EventName = "ConnectionPause", SkipEnabledCheck = true)] + private static partial void ConnectionPauseCore(ILogger logger, string connection); + + public static void ConnectionPause(ILogger logger, SocketMessageTransport connection) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ConnectionPauseCore(logger, connection.ToString()); + } + } + + [LoggerMessage(5, LogLevel.Debug, @"Connection ""{Connection}"" resumed.", EventName = "ConnectionResume", SkipEnabledCheck = true)] + private static partial void ConnectionResumeCore(ILogger logger, string connection); + + public static void ConnectionResume(ILogger logger, SocketMessageTransport connection) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ConnectionResumeCore(logger, connection.ToString()); + } + } + + [LoggerMessage(20, LogLevel.Debug, @"Connection ""{Connection}"" error during shutdown.", EventName = "ConnectionShutdownError", SkipEnabledCheck = true)] + private static partial void ConnectionShutdownErrorCore(ILogger logger, string connection, Exception ex); + + public static void ConnectionShutdownError(ILogger logger, SocketMessageTransport connection, Exception ex) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + ConnectionShutdownErrorCore(logger, connection.ToString(), ex); + } + } +} diff --git a/src/Orleans.Core/Networking/Transport/Sockets/TcpMessageTransportConnector.cs b/src/Orleans.Core/Networking/Transport/Sockets/TcpMessageTransportConnector.cs new file mode 100644 index 00000000000..29286998863 --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Sockets/TcpMessageTransportConnector.cs @@ -0,0 +1,82 @@ +using System; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using System.Net; +using Microsoft.Extensions.Options; + +namespace Orleans.Connections.Transport.Sockets; + +internal sealed class TcpMessageTransportOptions +{ + // We can expose these eventually, if desired. + internal LingerOption LingerOption { get; set; } = new LingerOption(true, 0); + internal bool NoDelay { get; set; } = true; + internal bool FastPath { get; set; } = true; + internal bool DualMode { get; set; } = true; + internal bool KeepAlive { get; set; } = true; + internal int KeepAliveTimeSeconds { get; set; } = 90; + internal int KeepAliveIntervalSeconds { get; set; } = 30; + internal int KeepAliveRetryCount { get; set; } = 10; +} + +/// +/// which creates TCP connections. +/// +internal sealed class TcpMessageTransportConnector : MessageTransportConnector +{ + private readonly IOptionsMonitor _options; + private readonly ILogger _logger; + + public TcpMessageTransportConnector(IOptionsMonitor options, ILoggerFactory loggerFactory) + { + _options = options; + _logger = loggerFactory.CreateLogger("Orleans.Connections.Transport.Sockets"); + } + + /// + public override IFeatureCollection Features { get; } = new FeatureCollection(); + + /// + public override bool IsValid => true; + + /// + public override async ValueTask CreateAsync(EndPoint endPoint, CancellationToken cancellationToken = default) + { + if (endPoint is not IPEndPoint ip) + { + throw new ConnectionAbortedException($"Endpoint {endPoint} is not a TCP endpoint"); + } + + var options = _options.CurrentValue; + + var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + + try + { + socket.LingerState = options.LingerOption; + socket.NoDelay = options.NoDelay; + socket.ConfigureKeepAlive(options, _logger); + + if (ip.AddressFamily == AddressFamily.InterNetworkV6) + { + socket.DualMode = options.DualMode; + } + + if (options.FastPath) + { + socket.EnableFastPath(noDelay: options.NoDelay); + } + + await socket.ConnectAsync(ip, cancellationToken).ConfigureAwait(false); + } + catch + { + socket.Dispose(); + throw; + } + + return await SocketMessageTransport.CreateAndStartAsync(socket, _logger).ConfigureAwait(false); + } +} diff --git a/src/Orleans.Core/Networking/Transport/Sockets/TcpMessageTransportListener.cs b/src/Orleans.Core/Networking/Transport/Sockets/TcpMessageTransportListener.cs new file mode 100644 index 00000000000..d0a4207b93c --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Sockets/TcpMessageTransportListener.cs @@ -0,0 +1,203 @@ +using System; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using System.Net; +using System.Diagnostics; +using Microsoft.Extensions.Options; + +namespace Orleans.Connections.Transport.Sockets; + +internal sealed class TcpMessageTransportListenerOptions +{ + public IPEndPoint? Endpoint { get; set; } + public bool Enabled { get; set; } = true; +} + +/// +/// which listens for TCP connections. +/// +internal sealed class TcpMessageTransportListener : MessageTransportListener +{ + private readonly IOptionsMonitor _tcpOptions; + private readonly IOptionsMonitor _listenerOptions; + private readonly object _lifecycleLock = new(); + private readonly CancellationTokenSource _closingCts = new(); + private Socket? _listenSocket; + private bool _disposed; + + internal TcpMessageTransportListener(string endpointName, IOptionsMonitor tcpOptions, IOptionsMonitor listenerOptions, ILoggerFactory loggerFactory) + { + Debug.Assert(loggerFactory != null); + _listenerOptions = listenerOptions; + _tcpOptions = tcpOptions; + ListenerName = endpointName; + Logger = loggerFactory.CreateLogger("Orleans.Connections.Transport.Sockets"); + } + + private ILogger Logger { get; } + + /// + public override FeatureCollection Features { get; } = new FeatureCollection(); + + /// + public override bool IsValid => _listenerOptions.Get(ListenerName).Enabled; + + /// + public override string ListenerName { get; } + + private Socket CreateListenSocket() + { + var options = _tcpOptions.Get(ListenerName); + var listenerOptions = _listenerOptions.Get(ListenerName); + var listenSocket = new Socket(listenerOptions.Endpoint!.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + try + { + listenSocket.LingerState = options.LingerOption; + listenSocket.NoDelay = options.NoDelay; + listenSocket.ConfigureKeepAlive(options, Logger); + + listenSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + + if (options.FastPath) + { + listenSocket.EnableFastPath(noDelay: options.NoDelay); + } + + // IPv6Any is expected to bind to both IPv6 and IPv4 + if (listenerOptions.Endpoint is IPEndPoint ip && ip.Address == IPAddress.IPv6Any) + { + listenSocket.DualMode = options.DualMode; + } + + return listenSocket; + } + catch + { + listenSocket.Dispose(); + throw; + } + } + + private void OnAcceptSocket(Socket socket) + { + var options = _tcpOptions.Get(ListenerName); + socket.NoDelay = options.NoDelay; + socket.ConfigureKeepAlive(options, Logger); + } + + public override ValueTask BindAsync(CancellationToken cancellationToken = default) + { + if (_listenSocket != null) + { + throw new InvalidOperationException("Transport already bound"); + } + + Socket? listenSocket = CreateListenSocket(); + + try + { + var listenerOptions = _listenerOptions.Get(ListenerName); + listenSocket.Bind(listenerOptions.Endpoint!); + listenSocket.Listen(512); + + _listenSocket = listenSocket; + listenSocket = null; + return default; + } + catch (SocketException e) when (e.SocketErrorCode == SocketError.AddressAlreadyInUse) + { + throw new AddressInUseException(e.Message, e); + } + finally + { + listenSocket?.Dispose(); + } + } + + public override async ValueTask AcceptAsync(CancellationToken cancellationToken = default) + { + using var ct = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closingCts.Token); + while (!ct.IsCancellationRequested) + { + try + { + var acceptSocket = await _listenSocket!.AcceptAsync(ct.Token).ConfigureAwait(false); + try + { + OnAcceptSocket(acceptSocket); + } + catch + { + acceptSocket.Dispose(); + throw; + } + + return await SocketMessageTransport.CreateAndStartAsync(acceptSocket, Logger).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Graceful termination. + return null; + } + catch (ObjectDisposedException) + { + // A call was made to UnbindAsync/DisposeAsync just return null which signals we're done + return null; + } + catch (SocketException e) when (e.SocketErrorCode == SocketError.OperationAborted) + { + // A call was made to UnbindAsync/DisposeAsync just return null which signals we're done + return null; + } + catch (SocketException) + { + // The connection got reset while it was in the backlog, so we try again. + SocketsLog.ConnectionReset(Logger, connection: "(null)"); + } + } + + return null; + } + + private async ValueTask UnbindCoreAsync() + { + Socket? listenSocket; + lock (_lifecycleLock) + { + if (_disposed) + { + return; + } + + listenSocket = _listenSocket; + } + + await _closingCts.CancelAsync().ConfigureAwait(false); + listenSocket?.Dispose(); + } + + public override ValueTask UnbindAsync(CancellationToken cancellationToken) => UnbindCoreAsync(); + + public override async ValueTask DisposeAsync() + { + Socket? listenSocket; + lock (_lifecycleLock) + { + if (_disposed) + { + return; + } + + _disposed = true; + listenSocket = _listenSocket; + } + + await _closingCts.CancelAsync().ConfigureAwait(false); + listenSocket?.Dispose(); + _closingCts.Dispose(); + await base.DisposeAsync(); + GC.SuppressFinalize(this); + } +} diff --git a/src/Orleans.Core/Networking/Transport/Streams/MessageTransportStream.cs b/src/Orleans.Core/Networking/Transport/Streams/MessageTransportStream.cs new file mode 100644 index 00000000000..13119c1e08f --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Streams/MessageTransportStream.cs @@ -0,0 +1,238 @@ +using System; +using System.Buffers; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Sources; +using Orleans.Serialization.Buffers; + +namespace Orleans.Connections.Transport.Streams; + +/// +/// implementation which reads and writes to a . +/// +internal sealed class MessageTransportStream(MessageTransport transport, MemoryPool memoryPool) : Stream +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Usage", + "CA2213:Disposable fields should be disposed", + Justification = "The transport lifecycle is owned by TlsMessageTransport; this stream only adapts I/O.")] + private readonly MessageTransport _transport = transport; + private readonly StreamWriteRequest _writeRequest = new(); + private readonly StreamReadRequest _readRequest = new(); + + /// + public override bool CanTimeout => true; + + /// + public override bool CanRead => true; + + /// + public override bool CanSeek => false; + + /// + public override bool CanWrite => true; + + /// + public override long Length => throw new NotSupportedException(); + + /// + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + /// + public MemoryPool MemoryPool { get; } = memoryPool; + + /// + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + /// + public override void SetLength(long value) => throw new NotSupportedException(); + + /// + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => ReadAsync(new Memory(buffer, offset, count), cancellationToken).AsTask(); + + /// + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => WriteAsync(new Memory(buffer, offset, count), cancellationToken).AsTask(); + + /// + public override int Read(byte[] buffer, int offset, int count) => + ReadAsync(new Memory(buffer, offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + /// + public override void Write(byte[] buffer, int offset, int count) => Write(new ReadOnlySpan(buffer, offset, count)); + + /// + public override int Read(Span buffer) + { + using var bytes = MemoryPool.Rent(buffer.Length); + var bytesRead = ReadAsync(bytes.Memory[..buffer.Length], CancellationToken.None).AsTask().GetAwaiter().GetResult(); + bytes.Memory.Span[..bytesRead].CopyTo(buffer); + return bytesRead; + } + + /// + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _readRequest.Reset(); + _readRequest.SetBuffer(buffer); + _readRequest.SetCancellationToken(cancellationToken); + if (!_transport.EnqueueRead(_readRequest)) + { + _readRequest.Reset(); + cancellationToken.ThrowIfCancellationRequested(); + return 0; + } + + using var registration = RegisterCancellation(cancellationToken); + return await _readRequest.OnProgressAsync().ConfigureAwait(false); + } + + /// + public override void Write(ReadOnlySpan buffer) + { + _writeRequest.Reset(); + _writeRequest.Write(buffer); + if (!_transport.EnqueueWrite(_writeRequest)) + { + throw new ObjectDisposedException("Network transport is unable to satisfy the request"); + } + + _writeRequest.OnCompleteAsync().AsTask().GetAwaiter().GetResult(); + } + + /// + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + _writeRequest.Reset(); + _writeRequest.Write(buffer); + if (!_transport.EnqueueWrite(_writeRequest)) + { + throw new ObjectDisposedException("Network transport is unable to satisfy the request"); + } + + using var registration = RegisterCancellation(cancellationToken); + await _writeRequest.OnCompleteAsync().ConfigureAwait(false); + } + + /// + public override ValueTask DisposeAsync() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + return default; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _writeRequest.Dispose(); + } + + base.Dispose(disposing); + } + + /// + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public override void Flush() { } + + private CancellationTokenRegistration RegisterCancellation(CancellationToken cancellationToken) + { + return cancellationToken.UnsafeRegister( + static state => + { + var (transport, token) = ((MessageTransport, CancellationToken))state!; + _ = CloseTransportAsync(transport, token); + }, + (_transport, cancellationToken)); + + static async Task CloseTransportAsync(MessageTransport transport, CancellationToken token) + { + try + { + await transport.CloseAsync(new OperationCanceledException(token), CancellationToken.None).ConfigureAwait(false); + } + catch (Exception exception) + { + System.Diagnostics.Debug.Fail($"Failed to close the transport after cancellation: {exception}"); + } + } + } + + private sealed class StreamWriteRequest : WriteRequest, IValueTaskSource, IDisposable + { + private ManualResetValueTaskSourceCore _signal = new() + { + RunContinuationsAsynchronously = true + }; + + private readonly ArcBufferWriter _bufferWriter = new(); + public StreamWriteRequest() + { + Buffers = new(_bufferWriter); + } + + public void Write(ReadOnlyMemory buffer) => Write(buffer.Span); + public void Write(ReadOnlySpan buffer) => _bufferWriter.Write(buffer); + public ValueTask OnCompleteAsync() => new(this, _signal.Version); + public override void SetResult() => _signal.SetResult(true); + public override void SetException(Exception error) => _signal.SetException(error); + public void GetResult(short token) => _signal.GetResult(token); + public ValueTaskSourceStatus GetStatus(short token) => _signal.GetStatus(token); + public void OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) => _signal.OnCompleted(continuation, state, token, flags); + public void Reset() => _signal.Reset(); + public void Dispose() => _bufferWriter.Dispose(); + } + + private sealed class StreamReadRequest : ReadRequest, IValueTaskSource + { + private ManualResetValueTaskSourceCore _completion = new(); + private Memory _buffer; + private CancellationToken _cancellationToken; + + public void SetBuffer(Memory buffer) => _buffer = buffer; + public void SetCancellationToken(CancellationToken cancellationToken) => _cancellationToken = cancellationToken; + + public override bool OnRead(ArcBufferReader bufferReader) + { + if (_buffer.Length == 0) + { + _completion.SetResult(0); + return true; + } + + if (bufferReader.Length == 0) + { + return false; + } + + var bytesRead = Math.Min(bufferReader.Length, _buffer.Length); + bufferReader.Consume(_buffer.Span[..bytesRead]); + _completion.SetResult(bytesRead); + return true; + } + + public override void OnCanceled() + { + if (_cancellationToken.IsCancellationRequested) + { + _completion.SetException(new OperationCanceledException(_cancellationToken)); + } + else + { + _completion.SetResult(0); + } + } + + public ValueTask OnProgressAsync() => new(this, _completion.Version); + public override void OnError(Exception error) => _completion.SetException(error); + void IValueTaskSource.OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) => _completion.OnCompleted(continuation, state, token, flags); + int IValueTaskSource.GetResult(short token) => _completion.GetResult(token); + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) => _completion.GetStatus(token); + public void Reset() => _completion.Reset(); + } +} diff --git a/src/Orleans.Core/Networking/Transport/Streams/StreamMessageTransport.cs b/src/Orleans.Core/Networking/Transport/Streams/StreamMessageTransport.cs new file mode 100644 index 00000000000..3f08f906b6e --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/Streams/StreamMessageTransport.cs @@ -0,0 +1,426 @@ +using Microsoft.Extensions.Logging; +using Orleans.Runtime; +using Orleans.Runtime.Internal; +using Orleans.Serialization.Buffers; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Orleans.Connections.Transport.Streams; + +internal abstract partial class StreamMessageTransport : MessageTransportBase +{ + private readonly ILogger _logger; + private readonly SingleWaiterAutoResetEvent _writerSignal = new(); + private readonly SingleWaiterAutoResetEvent _readerSignal = new(); + private readonly Queue _pendingWrites = new(); + private readonly Queue _pendingReads = new(); + private readonly CancellationTokenSource _connectionClosingCts = new(); + private readonly CancellationTokenSource _connectionClosedCts = new(); + private readonly object _writesLock = new(); + private readonly object _readsLock = new(); + private readonly object _disposeLock = new(); + private Task? _runTask; + private Exception? _shutdownReason; + private bool _readsCompleted; + private bool _writesCompleted; + private volatile bool _streamDisposed; + private int _disposeStarted; + + protected StreamMessageTransport(ILogger logger) + { + _logger = logger; + } + + protected abstract Stream Stream { get; } + + public virtual void Start() + { + using var _ = new ExecutionContextSuppressor(); + _runTask = Task.Run(RunAsync); + } + + public override CancellationToken Closed => _connectionClosedCts.Token; + + public override async ValueTask CloseAsync(Exception? closeException, CancellationToken cancellationToken = default) + { + _shutdownReason ??= closeException; + + // Early exit if already closed + if (_connectionClosedCts.IsCancellationRequested) + { + return; + } + + // Signal loops to stop + await _connectionClosingCts.CancelAsync().ConfigureAwait(false); + _readerSignal.Signal(); + _writerSignal.Signal(); + + // If no run task, just dispose the stream directly + if (_runTask is null) + { + DisposeStream(); + await _connectionClosedCts.CancelAsync().ConfigureAwait(false); + return; + } + + try + { + await _runTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + DisposeStream(); + } + } + + /// + /// Disposes the underlying stream to force-close any pending I/O operations. + /// + private void DisposeStream() + { + if (_streamDisposed) + { + return; + } + + lock (_disposeLock) + { + if (_streamDisposed) + { + return; + } + + _streamDisposed = true; + + try + { + Stream.Dispose(); + } + catch + { + // Ignore errors during disposal + } + } + } + + public override async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + { + return; + } + + try + { + // Ensure stream is disposed, even if CloseAsync wasn't called or timed out + DisposeStream(); + + // Signal that we're closing if not already done + await _connectionClosingCts.CancelAsync().ConfigureAwait(false); + _readerSignal.Signal(); + _writerSignal.Signal(); + + if (_runTask is { } runTask) + { + await runTask.ConfigureAwait(false); + } + else + { + await _connectionClosedCts.CancelAsync().ConfigureAwait(false); + } + + await base.DisposeAsync().ConfigureAwait(false); + } + finally + { + _connectionClosingCts.Dispose(); + _connectionClosedCts.Dispose(); + GC.SuppressFinalize(this); + } + } + + public override bool EnqueueRead(ReadRequest request) + { + if (_connectionClosingCts.IsCancellationRequested) + { + return false; + } + + lock (_readsLock) + { + if (_readsCompleted) + { + return false; + } + + _pendingReads.Enqueue(request); + } + + _readerSignal.Signal(); + return true; + } + + public override bool EnqueueWrite(WriteRequest request) + { + if (_connectionClosingCts.IsCancellationRequested) + { + return false; + } + + lock (_writesLock) + { + if (_writesCompleted) + { + return false; + } + + _pendingWrites.Enqueue(request); + } + + _writerSignal.Signal(); + return true; + } + + private async Task RunAsync() + { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + + try + { + await RunAsyncCore(); + } + catch (Exception exception) + { + _shutdownReason ??= exception; + FailPendingRequests(exception); + } + finally + { + await _connectionClosingCts.CancelAsync().ConfigureAwait(false); + _readerSignal.Signal(); + _writerSignal.Signal(); + await _connectionClosedCts.CancelAsync().ConfigureAwait(false); + } + + void FailPendingRequests(Exception exception) + { + lock (_readsLock) + { + _readsCompleted = true; + while (_pendingReads.TryDequeue(out var request)) + { + request.OnError(exception); + } + } + + lock (_writesLock) + { + _writesCompleted = true; + while (_pendingWrites.TryDequeue(out var request)) + { + request.SetException(exception); + } + } + } + } + + protected virtual async Task RunAsyncCore() + { + try + { + var readsTask = ProcessReads(); + var writesTask = ProcessWrites(); + await readsTask; + await writesTask; + } + catch (Exception exception) + { + _shutdownReason ??= exception; + } + finally + { + await _connectionClosedCts.CancelAsync().ConfigureAwait(false); + } + } + + private async Task ProcessReads() + { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + Exception? error = default; + ReadRequest? operation = default; + bool isGracefulTermination = false; + using ArcBufferWriter bufferWriter = new(); + var reader = new ArcBufferReader(bufferWriter); + try + { + while (!_connectionClosingCts.IsCancellationRequested) + { + while (TryDequeue(out operation)) + { + while (true) + { + if (operation.OnRead(reader)) + { + break; + } + + var bytesRead = await Stream.ReadAsync(bufferWriter.GetMemory(), _connectionClosingCts.Token); + if (bytesRead == 0) + { + goto gracefulTermination; + } + + bufferWriter.AdvanceWriter(bytesRead); + } + + operation = null; + } + + await _readerSignal.WaitAsync(); + } + +gracefulTermination: + isGracefulTermination = true; + } + catch (Exception exception) + { + // If we initiated shutdown (stream disposed or closing requested), treat as graceful + if (_connectionClosingCts.IsCancellationRequested || _streamDisposed) + { + isGracefulTermination = true; + } + else + { + error ??= exception; + isGracefulTermination = false; + } + } + finally + { + _shutdownReason ??= error; + await _connectionClosingCts.CancelAsync().ConfigureAwait(false); + + lock (_readsLock) + { + _readsCompleted = true; + } + + if (isGracefulTermination) + { + operation?.OnCanceled(); + } + else + { + Debug.Assert(error is not null); + operation?.OnError(error); + } + + while (TryDequeue(out operation)) + { + if (isGracefulTermination) + { + operation.OnCanceled(); + } + else + { + Debug.Assert(error is not null); + operation.OnError(error); + } + } + + _writerSignal.Signal(); + + // Only log unexpected errors (not when we intentionally disposed the stream) + if (error is not null && !_streamDisposed) + { + LogProcessReadsFailure(_logger, error); + } + } + + bool TryDequeue([NotNullWhen(true)] out ReadRequest? operation) + { + lock (_readsLock) + { + return _pendingReads.TryDequeue(out operation); + } + } + } + + private async Task ProcessWrites() + { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + Exception? error = default; + WriteRequest? operation = default; + try + { + while (!_connectionClosingCts.IsCancellationRequested) + { + while (TryDequeue(out operation)) + { + using var slice = operation.Buffers.ConsumeSlice(operation.Buffers.Length); + foreach (var buffer in slice.MemorySegments) + { + await Stream.WriteAsync(buffer, _connectionClosingCts.Token); + } + + operation.SetResult(); + operation = null; + } + + await _writerSignal.WaitAsync(); + } + } + catch (Exception exception) + { + // Don't treat as error if we initiated shutdown + if (!_connectionClosingCts.IsCancellationRequested && !_streamDisposed) + { + error = exception; + } + } + finally + { + _shutdownReason ??= error; + await _connectionClosingCts.CancelAsync().ConfigureAwait(false); + _readerSignal.Signal(); + var requestError = _shutdownReason ?? new ConnectionClosedException(); + operation?.SetException(requestError); + + // Only log unexpected errors (not when we intentionally disposed the stream) + if (error is not null && !_streamDisposed) + { + LogProcessWritesFailure(_logger, error); + } + + lock (_writesLock) + { + _writesCompleted = true; + while (_pendingWrites.TryDequeue(out operation)) + { + operation.SetException(requestError); + } + } + + } + + bool TryDequeue([NotNullWhen(true)] out WriteRequest? operation) + { + lock (_writesLock) + { + return _pendingWrites.TryDequeue(out operation); + } + } + } + + [LoggerMessage(0, LogLevel.Error, $"Unexpected exception in {nameof(StreamMessageTransport)}.{nameof(ProcessReads)}.")] + private static partial void LogProcessReadsFailure(ILogger logger, Exception exception); + + [LoggerMessage(0, LogLevel.Error, $"Unexpected exception in {nameof(StreamMessageTransport)}.{nameof(ProcessWrites)}.")] + private static partial void LogProcessWritesFailure(ILogger logger, Exception exception); +} diff --git a/src/Orleans.Core/Networking/Transport/TlsMessageTransportConnectorMiddleware.cs b/src/Orleans.Core/Networking/Transport/TlsMessageTransportConnectorMiddleware.cs new file mode 100644 index 00000000000..a1f61a6835f --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/TlsMessageTransportConnectorMiddleware.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.Connections.Transport.Security; + +namespace Orleans.Connections.Transport; + +/// +/// Middleware which adds TLS to all instances created by a . +/// +internal sealed class TlsMessageTransportConnectorMiddleware : IMessageTransportConnectorMiddleware +{ + private readonly IOptionsMonitor _tlsOptions; + private readonly ILoggerFactory _loggerFactory; + + public TlsMessageTransportConnectorMiddleware(IOptionsMonitor tlsOptions, ILoggerFactory loggerFactory) + { + _tlsOptions = tlsOptions; + _loggerFactory = loggerFactory; + } + + /// + public MessageTransportConnector Apply(MessageTransportConnector transport) => new TlsMessageTransportConnector(transport, _tlsOptions, _loggerFactory); +} + +/// +/// Middleware which adds TLS to all instances created by a . +/// +internal sealed class TlsMessageTransportListenerMiddleware : IMessageTransportListenerMiddleware +{ + private readonly IOptionsMonitor _tlsOptions; + private readonly ILoggerFactory _loggerFactory; + + public TlsMessageTransportListenerMiddleware(IOptionsMonitor tlsOptions, ILoggerFactory loggerFactory) + { + _tlsOptions = tlsOptions; + _loggerFactory = loggerFactory; + } + + /// + public MessageTransportListener Apply(MessageTransportListener input) => new TlsMessageTransportListener(input, _tlsOptions, _loggerFactory); +} diff --git a/src/Orleans.Core/Networking/Transport/WriteRequest.cs b/src/Orleans.Core/Networking/Transport/WriteRequest.cs new file mode 100644 index 00000000000..040b6a0e52a --- /dev/null +++ b/src/Orleans.Core/Networking/Transport/WriteRequest.cs @@ -0,0 +1,33 @@ +using System; +using Orleans.Serialization.Buffers; + +namespace Orleans.Connections.Transport; + +/// +/// Represents a write operation submitted to a . +/// +/// +/// Once accepted by a transport, the request and its buffers remain transport-owned until a completion callback returns. +/// +public abstract class WriteRequest +{ + /// + /// Gets the buffers to write. + /// + /// + /// The contents remain valid and unchanged while the request is owned by the transport. + /// + public ArcBufferReader Buffers { get; protected set; } + internal virtual bool HasLargeMessages => false; + + /// + /// Completes the request after all buffered data has been written. + /// + public abstract void SetResult(); + + /// + /// Completes the request with an error. + /// + /// The error which terminated the write. + public abstract void SetException(Exception error); +} diff --git a/src/Orleans.Core/Orleans.Core.csproj b/src/Orleans.Core/Orleans.Core.csproj index 1f58327219e..411bc41ff8e 100644 --- a/src/Orleans.Core/Orleans.Core.csproj +++ b/src/Orleans.Core/Orleans.Core.csproj @@ -38,7 +38,6 @@ - @@ -68,6 +67,7 @@ + @@ -75,6 +75,7 @@ + diff --git a/src/Orleans.Core/Runtime/InvokableObjectManager.cs b/src/Orleans.Core/Runtime/InvokableObjectManager.cs index 7885b9c44a1..f45a8881aa6 100644 --- a/src/Orleans.Core/Runtime/InvokableObjectManager.cs +++ b/src/Orleans.Core/Runtime/InvokableObjectManager.cs @@ -213,6 +213,7 @@ private void ReceiveMessage(Message message, ref bool ownsAdmission) LogObserverGarbageCollected(_manager.logger, this.ObserverId, message); // Try to remove. If it's not there, we don't care. _manager.TryDeregister(this.ObserverId); + message.Dispose(); return; } @@ -306,6 +307,10 @@ private void ReceiveMessage(Message message, ref bool ownsAdmission) } } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Each dequeued message is transferred to ProcessMessageAsync, which completes or rejects the request.")] private async Task LocalObjectMessagePumpAsync() { while (TryDequeueMessage(out var message)) @@ -339,6 +344,7 @@ private async Task ProcessMessageAsync(Message message) if (message.IsExpired) { _manager.messagingTrace.OnDropExpiredMessage(message, MessagingInstruments.Phase.Invoke); + message.Dispose(); return; } diff --git a/src/Orleans.Core/Runtime/OutsideRuntimeClient.cs b/src/Orleans.Core/Runtime/OutsideRuntimeClient.cs index 57d1c542829..01a2d211729 100644 --- a/src/Orleans.Core/Runtime/OutsideRuntimeClient.cs +++ b/src/Orleans.Core/Runtime/OutsideRuntimeClient.cs @@ -258,10 +258,15 @@ private void HandleMessage(Message message) } default: LogMessageNotSupported(logger, message); + message.Dispose(); break; } } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the response message is transferred to the message center.")] public void SendResponse(Message request, Response response) { ThrowIfDisposed(); @@ -272,6 +277,10 @@ public void SendResponse(Message request, Response response) MessageCenter!.SendMessage(message); } + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the request message is transferred to callback tracking or the message center.")] public void SendRequest(GrainReference target, IInvokable request, IResponseCompletionSource? context, InvokeMethodOptions options) { ThrowIfDisposed(); @@ -339,6 +348,7 @@ public void ReceiveResponse(Message response) if (response.Result is Message.ResponseTypes.Status) { + using var statusMessage = response; var status = (StatusResponse)response.BodyObject!; callbacks.TryGetValue(response.Id, out var callback); var request = callback?.Message; @@ -384,6 +394,7 @@ public void ReceiveResponse(Message response) else { LogDebugNoCallbackForResponseMessage(logger, response); + response.Dispose(); } } diff --git a/src/Orleans.Runtime/Versions/SingleWaiterAutoResetEvent.cs b/src/Orleans.Core/Utils/SingleWaiterAutoResetEvent.cs similarity index 100% rename from src/Orleans.Runtime/Versions/SingleWaiterAutoResetEvent.cs rename to src/Orleans.Core/Utils/SingleWaiterAutoResetEvent.cs diff --git a/src/Orleans.Runtime/Utilities/StripedMpscBuffer.cs b/src/Orleans.Core/Utils/StripedMpscBuffer.cs similarity index 100% rename from src/Orleans.Runtime/Utilities/StripedMpscBuffer.cs rename to src/Orleans.Core/Utils/StripedMpscBuffer.cs diff --git a/src/Orleans.Runtime/Catalog/ActivationData.cs b/src/Orleans.Runtime/Catalog/ActivationData.cs index a66723d8eef..ba6b807001b 100644 --- a/src/Orleans.Runtime/Catalog/ActivationData.cs +++ b/src/Orleans.Runtime/Catalog/ActivationData.cs @@ -1646,6 +1646,7 @@ public void ReceiveMessage(Message message) { _shared.MessagingProcessingInstruments.OnDispatcherMessageProcessedError(message); _shared.InternalRuntime.MessagingTrace.OnDropExpiredMessage(message, MessagingInstruments.Phase.Dispatch); + message.Dispose(); return; } diff --git a/src/Orleans.Runtime/CompatibilitySuppressions.xml b/src/Orleans.Runtime/CompatibilitySuppressions.xml new file mode 100644 index 00000000000..b5f10745e39 --- /dev/null +++ b/src/Orleans.Runtime/CompatibilitySuppressions.xml @@ -0,0 +1,18 @@ + + + + + CP0001 + T:Orleans.Configuration.SiloConnectionOptions + lib/net10.0/Orleans.Runtime.dll + lib/net10.0/Orleans.Runtime.dll + true + + + CP0001 + T:Orleans.Configuration.SiloConnectionOptions + lib/net8.0/Orleans.Runtime.dll + lib/net8.0/Orleans.Runtime.dll + true + + diff --git a/src/Orleans.Runtime/Configuration/SiloConnectionOptions.cs b/src/Orleans.Runtime/Configuration/SiloConnectionOptions.cs deleted file mode 100644 index 811411c61e0..00000000000 --- a/src/Orleans.Runtime/Configuration/SiloConnectionOptions.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using Microsoft.AspNetCore.Connections; - -namespace Orleans.Configuration -{ - /// - /// Options for configuring silo networking. - /// Implements the - /// - /// - public class SiloConnectionOptions : SiloConnectionOptions.ISiloConnectionBuilderOptions - { - private readonly ConnectionBuilderDelegates siloOutboundDelegates = new ConnectionBuilderDelegates(); - private readonly ConnectionBuilderDelegates siloInboundDelegates = new ConnectionBuilderDelegates(); - private readonly ConnectionBuilderDelegates gatewayInboundDelegates = new ConnectionBuilderDelegates(); - - /// - /// Configures silo outbound connections. - /// - /// The configuration delegate. - public void ConfigureSiloOutboundConnection(Action configure) => this.siloOutboundDelegates.Add(configure); - - /// - /// Configures silo inbound connections from other silos. - /// - /// The configuration delegate. - public void ConfigureSiloInboundConnection(Action configure) => this.siloInboundDelegates.Add(configure); - - /// - /// Configures silo inbound connections from clients. - /// - /// The configuration delegate. - public void ConfigureGatewayInboundConnection(Action configure) => this.gatewayInboundDelegates.Add(configure); - - /// - void ISiloConnectionBuilderOptions.ConfigureSiloOutboundBuilder(IConnectionBuilder builder) => this.siloOutboundDelegates.Invoke(builder); - - /// - void ISiloConnectionBuilderOptions.ConfigureSiloInboundBuilder(IConnectionBuilder builder) => this.siloInboundDelegates.Invoke(builder); - - /// - void ISiloConnectionBuilderOptions.ConfigureGatewayInboundBuilder(IConnectionBuilder builder) => this.gatewayInboundDelegates.Invoke(builder); - - /// - /// Options for silo networking. - /// - public interface ISiloConnectionBuilderOptions - { - /// - /// Configures the silo outbound connection builder. - /// - /// The builder. - public void ConfigureSiloOutboundBuilder(IConnectionBuilder builder); - - /// - /// Configures the silo inbound connection builder. - /// - /// The builder. - public void ConfigureSiloInboundBuilder(IConnectionBuilder builder); - - /// - /// Configures the silo gateway connection builder. - /// - /// The builder. - public void ConfigureGatewayInboundBuilder(IConnectionBuilder builder); - } - } -} diff --git a/src/Orleans.Runtime/Core/InsideRuntimeClient.cs b/src/Orleans.Runtime/Core/InsideRuntimeClient.cs index 2fbb9203443..8ec03015ce6 100644 --- a/src/Orleans.Runtime/Core/InsideRuntimeClient.cs +++ b/src/Orleans.Runtime/Core/InsideRuntimeClient.cs @@ -454,6 +454,7 @@ public void ReceiveResponse(Message message) break; case Message.RejectionTypes.CacheInvalidation when message.HasCacheInvalidationHeader: // The message targeted an invalid (eg, defunct) activation and this response serves only to invalidate this silo's activation cache. + message.Dispose(); return; default: LogErrorUnsupportedRejectionType(this.logger, rejection.RejectionType); @@ -479,11 +480,13 @@ private void ProcessResponseCallback(Message message) else { LogDebugNoCallbackForResponse(this.logger, message); + message.Dispose(); } } private void ProcessStatusResponse(Message message) { + using var statusMessage = message; var status = (StatusResponse)message.BodyObject!; callbacks.TryGetValue((message.TargetGrain, message.Id), out var callback); var request = callback?.Message; diff --git a/src/Orleans.Runtime/Hosting/DefaultSiloServices.cs b/src/Orleans.Runtime/Hosting/DefaultSiloServices.cs index 51004ce3515..9fe503480c7 100644 --- a/src/Orleans.Runtime/Hosting/DefaultSiloServices.cs +++ b/src/Orleans.Runtime/Hosting/DefaultSiloServices.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using Microsoft.AspNetCore.Connections; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -11,10 +10,12 @@ using Orleans.Configuration; using Orleans.Configuration.Internal; using Orleans.Configuration.Validators; +using Orleans.Connections; +using Orleans.Connections.Transport; +using Orleans.Connections.Transport.Sockets; using Orleans.Core; using Orleans.GrainReferences; using Orleans.Metadata; -using Orleans.Networking.Shared; using Orleans.Placement.Repartitioning; using Orleans.Providers; using Orleans.Runtime; @@ -32,6 +33,7 @@ using Orleans.Runtime.Versions.Compatibility; using Orleans.Runtime.Versions.Selector; using Orleans.Serialization; +using Orleans.Serialization.Session; using Orleans.Serialization.Cloning; using Orleans.Serialization.Serializers; using Orleans.Serialization.TypeSystem; @@ -269,7 +271,7 @@ internal static void AddDefaultServices(ISiloBuilder builder) services.AddKeyedSingleton(nameof(AllVersionsCompatible)); services.AddKeyedSingleton(nameof(BackwardCompatible)); services.AddKeyedSingleton(nameof(StrictVersionCompatible)); - // Compatability directors + services.AddKeyedSingleton(typeof(BackwardCompatible)); services.AddKeyedSingleton(typeof(AllVersionsCompatible)); services.AddKeyedSingleton(typeof(StrictVersionCompatible)); @@ -404,23 +406,15 @@ internal static void AddDefaultServices(ISiloBuilder builder) (sp, _) => sp.GetRequiredService()); // Networking + services.AddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.AddSingleton, ConnectionManagerLifecycleAdapter>(); services.AddSingleton, SiloConnectionMaintainer>(); - services.AddKeyedSingleton( - SiloConnectionFactory.ServicesKey, - (sp, key) => ActivatorUtilities.CreateInstance(sp)); - services.AddKeyedSingleton( - SiloConnectionListener.ServicesKey, - (sp, key) => ActivatorUtilities.CreateInstance(sp)); - services.AddKeyedSingleton( - GatewayConnectionListener.ServicesKey, - (sp, key) => ActivatorUtilities.CreateInstance(sp)); - services.AddSerializer(); services.AddSingleton(); services.AddSingleton(); @@ -430,22 +424,50 @@ internal static void AddDefaultServices(ISiloBuilder builder) services.AddSingleton, ConfigureOrleansJsonSerializerOptions>(); services.AddSingleton(); - services.TryAddTransient(sp => ActivatorUtilities.CreateInstance( - sp, - sp.GetRequiredService>().Value)); + services.TryAddSingleton(sp => + { + var sessionPool = sp.GetRequiredService(); + var options = sp.GetRequiredService>(); + return () => new MessageSerializer(sessionPool, options.Value); + }); + services.TryAddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddFromExisting(); - // Use Orleans server. - services.AddSingleton, SiloConnectionListener>(); - services.AddSingleton, GatewayConnectionListener>(); - services.AddSingleton(); - services.AddSingleton(); - // Activation migration services.AddSingleton(); services.AddFromExisting(); + + // Use Orleans server. + services.AddSingleton(); + services.AddSingleton(sp => new TcpMessageTransportListener( + "gateway", + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService())); + services.AddOptions("gateway").Configure>((listenerOptions, endpointOptions) => + { + listenerOptions.Endpoint = endpointOptions.Value.GetListeningProxyEndpoint(); + listenerOptions.Enabled = listenerOptions.Endpoint is not null; + }); + + services.AddSingleton(sp => new TcpMessageTransportListener( + "silo", + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService())); + services.AddOptions("silo").Configure>((listenerOptions, endpointOptions) => + { + listenerOptions.Endpoint = endpointOptions.Value.GetListeningSiloEndpoint(); + }); + + services.AddSingleton(); + services.AddFromExisting, SiloConnectionListener>(); + services.AddSingleton(); + services.AddFromExisting, GatewayConnectionListener>(); + services.AddFromExisting, ActivationMigrationManager>(); services.AddSingleton(); diff --git a/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs b/src/Orleans.Runtime/Hosting/SiloTlsHostingExtensions.ISiloBuilder.cs similarity index 56% rename from src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs rename to src/Orleans.Runtime/Hosting/SiloTlsHostingExtensions.ISiloBuilder.cs index 8b7376ef81a..6423040d14b 100644 --- a/src/Orleans.Connections.Security/Hosting/HostingExtensions.ISiloBuilder.cs +++ b/src/Orleans.Runtime/Hosting/SiloTlsHostingExtensions.ISiloBuilder.cs @@ -1,11 +1,17 @@ using System; using System.Security.Cryptography.X509Certificates; -using Orleans.Configuration; -using Orleans.Connections.Security; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Orleans.Connections.Transport; +using Orleans.Connections.Transport.Security; +using Orleans.Runtime.Messaging; namespace Orleans.Hosting { - public static partial class OrleansConnectionSecurityHostingExtensions + /// + /// Extensions for configuring a silo with TLS. + /// + public static partial class SiloTlsHostingExtensions { /// /// Configures TLS. @@ -25,10 +31,7 @@ public static ISiloBuilder UseTls( StoreLocation location, Action configureOptions) { - if (configureOptions is null) - { - throw new ArgumentNullException(nameof(configureOptions)); - } + ArgumentNullException.ThrowIfNull(configureOptions); return builder.UseTls( CertificateLoader.LoadFromStoreCert(subject, storeName.ToString(), location, allowInvalid, server: true), @@ -47,19 +50,13 @@ public static ISiloBuilder UseTls( X509Certificate2 certificate, Action configureOptions) { - if (certificate is null) - { - throw new ArgumentNullException(nameof(certificate)); - } + ArgumentNullException.ThrowIfNull(certificate); - if (configureOptions is null) - { - throw new ArgumentNullException(nameof(configureOptions)); - } + ArgumentNullException.ThrowIfNull(configureOptions); if (!certificate.HasPrivateKey) { - TlsConnectionBuilderExtensions.ThrowNoPrivateKey(certificate, nameof(certificate)); + throw new ArgumentException($"Certificate {certificate.ToString(verbose: true)} does not contain a private key", nameof(certificate)); } return builder.UseTls(options => @@ -79,14 +76,11 @@ public static ISiloBuilder UseTls( this ISiloBuilder builder, X509Certificate2 certificate) { - if (certificate is null) - { - throw new ArgumentNullException(nameof(certificate)); - } + ArgumentNullException.ThrowIfNull(certificate); if (!certificate.HasPrivateKey) { - TlsConnectionBuilderExtensions.ThrowNoPrivateKey(certificate, nameof(certificate)); + throw new ArgumentException($"Certificate {certificate.ToString(verbose: true)} does not contain a private key", nameof(certificate)); } return builder.UseTls(options => @@ -105,40 +99,27 @@ public static ISiloBuilder UseTls( this ISiloBuilder builder, Action configureOptions) { - if (configureOptions is null) - { - throw new ArgumentNullException(nameof(configureOptions)); - } + ArgumentNullException.ThrowIfNull(configureOptions); - var options = new TlsOptions(); - configureOptions(options); - if (options.LocalCertificate is null && options.LocalServerCertificateSelector is null) - { - throw new InvalidOperationException("No certificate specified"); - } + var services = builder.Services; - if (options.LocalCertificate is X509Certificate2 certificate && !certificate.HasPrivateKey) - { - TlsConnectionBuilderExtensions.ThrowNoPrivateKey(certificate, $"{nameof(TlsOptions)}.{nameof(TlsOptions.LocalCertificate)}"); - } + // Configure TLS options for each of the connection types. + services.Configure(configureOptions); + builder.Services.AddSingleton(sp => new ClientTlsHostingExtensions.TlsOptionsValidator(sp.GetRequiredService>().Value)); + services.AddOptions(SiloConnectionListener.DefaultListenerName).Configure(configureOptions); + builder.Services.AddSingleton( + sp => new ClientTlsHostingExtensions.TlsOptionsValidator( + sp.GetRequiredService>().Get(SiloConnectionListener.DefaultListenerName), + isServer: true)); + services.AddOptions(GatewayConnectionListener.DefaultListenerName).Configure(configureOptions); + builder.Services.AddSingleton( + sp => new ClientTlsHostingExtensions.TlsOptionsValidator( + sp.GetRequiredService>().Get(GatewayConnectionListener.DefaultListenerName), + isServer: true)); - return builder.Configure(connectionOptions => - { - connectionOptions.ConfigureSiloInboundConnection(connectionBuilder => - { - connectionBuilder.UseServerTls(options); - }); - - connectionOptions.ConfigureGatewayInboundConnection(connectionBuilder => - { - connectionBuilder.UseServerTls(options); - }); - - connectionOptions.ConfigureSiloOutboundConnection(connectionBuilder => - { - connectionBuilder.UseClientTls(options); - }); - }); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + return builder; } } } diff --git a/src/Orleans.Runtime/Messaging/Gateway.cs b/src/Orleans.Runtime/Messaging/Gateway.cs index ad7f7d858f2..8b43ec62a0d 100644 --- a/src/Orleans.Runtime/Messaging/Gateway.cs +++ b/src/Orleans.Runtime/Messaging/Gateway.cs @@ -6,12 +6,12 @@ using System.Net; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Orleans.ClientObservers; using Orleans.Configuration; +using Orleans.Connections.Transport; using Orleans.Core.Diagnostics; using Orleans.Runtime.Internal; @@ -451,7 +451,6 @@ private bool TrySend(GatewayInboundConnection connection, Message message) try { connection.Send(message); - _gateway.GatewayInstruments.OnGatewaySent(); return true; } catch (Exception exception) diff --git a/src/Orleans.Runtime/Messaging/MessageCenter.cs b/src/Orleans.Runtime/Messaging/MessageCenter.cs index 4f30f375e73..a7af6263f7d 100644 --- a/src/Orleans.Runtime/Messaging/MessageCenter.cs +++ b/src/Orleans.Runtime/Messaging/MessageCenter.cs @@ -164,6 +164,7 @@ public void SendMessage(Message msg) else { this.messagingTrace.OnDropBlockedApplicationMessage(msg); + msg.Dispose(); } return; @@ -183,6 +184,7 @@ public void SendMessage(Message msg) if (msg.IsExpired) { this.messagingTrace.OnDropExpiredMessage(msg, MessagingInstruments.Phase.Send); + msg.Dispose(); return; } @@ -223,6 +225,10 @@ public void SendMessage(Message msg) this.messagingTrace.OnRejectSendMessageToDeadSilo(_siloAddress, msg); this.SendRejection(msg, Message.RejectionTypes.Transient, "Target silo is known to be dead", new SiloUnavailableException()); } + else + { + msg.Dispose(); + } return; } @@ -264,21 +270,32 @@ public void RejectMessage( Exception? exc, string? rejectInfo = null) { - if (message.Direction == Message.Directions.Request - || (message.Direction == Message.Directions.OneWay && message.HasCacheInvalidationHeader)) + try { - this.messagingTrace.OnDispatcherRejectMessage(message, rejectionType, rejectInfo, exc); + if (message.Direction == Message.Directions.Request + || (message.Direction == Message.Directions.OneWay && message.HasCacheInvalidationHeader)) + { + this.messagingTrace.OnDispatcherRejectMessage(message, rejectionType, rejectInfo, exc); - var str = $"{rejectInfo} {exc}"; - var rejection = this.messageFactory.CreateRejectionResponse(message, rejectionType, str, exc); - SendMessage(rejection); + SendRejectionResponse(message, rejectionType, $"{rejectInfo} {exc}", exc); + } + else + { + this.messagingTrace.OnDispatcherDiscardedRejection(message, rejectionType, rejectInfo, exc); + } } - else + finally { - this.messagingTrace.OnDispatcherDiscardedRejection(message, rejectionType, rejectInfo, exc); + message.Dispose(); } } + private void SendRejectionResponse(Message message, Message.RejectionTypes rejectionType, string reason, Exception? exception) + { + var rejection = messageFactory.CreateRejectionResponse(message, rejectionType, reason, exception); + SendMessage(rejection); + } + internal void ProcessRequestsToInvalidActivation( List messages, GrainAddress? oldAddress, @@ -402,11 +419,11 @@ private void TryForwardRequest(Message message, GrainAddress? oldAddress, GrainA // If the message was a one-way message, send a cache invalidation response even if the message was successfully forwarded. if (message.Direction == Message.Directions.OneWay) { - this.RejectMessage( + SendRejectionResponse( message, Message.RejectionTypes.CacheInvalidation, - exc, - "OneWay message sent to invalid activation"); + $"OneWay message sent to invalid activation {exc}", + exc); sentRejection = true; } @@ -418,6 +435,10 @@ private void TryForwardRequest(Message message, GrainAddress? oldAddress, GrainA var str = $"Forwarding failed: tried to forward message {message} for {message.ForwardCount} times after \"{failedOperation}\" to invalid activation. Rejecting now."; RejectMessage(message, Message.RejectionTypes.Transient, exc, str); } + else + { + message.Dispose(); + } } } } @@ -595,6 +616,8 @@ private void ProcessMessageToNonExistentActivation(Message msg) SendMessage(response); } + + msg.Dispose(); } else { @@ -608,20 +631,27 @@ private void ProcessMessageToNonExistentActivation(Message msg) internal void SendRejection(Message msg, Message.RejectionTypes rejectionType, string reason, Exception? exception = null) { - _messagingInstruments.OnRejectedMessage(msg); - - if (msg.Direction is Message.Directions.Response && msg.Result is Message.ResponseTypes.Rejection) + try { - // Do not send reject a rejection locally, it will create a stack overflow - _messagingInstruments.OnDroppedSentMessage(msg); - LogDebugDroppingRejection(log, msg); + _messagingInstruments.OnRejectedMessage(msg); + + if (msg.Direction is Message.Directions.Response && msg.Result is Message.ResponseTypes.Rejection) + { + // Do not send reject a rejection locally, it will create a stack overflow + _messagingInstruments.OnDroppedSentMessage(msg); + LogDebugDroppingRejection(log, msg); + } + else + { + if (string.IsNullOrEmpty(reason)) reason = $"Rejection from silo {this._siloAddress} - Unknown reason."; + var error = this.messageFactory.CreateRejectionResponse(msg, rejectionType, reason, exception); + // rejection msgs are always originated in the local silo, they are never remote. + this.ReceiveMessage(error); + } } - else + finally { - if (string.IsNullOrEmpty(reason)) reason = $"Rejection from silo {this._siloAddress} - Unknown reason."; - var error = this.messageFactory.CreateRejectionResponse(msg, rejectionType, reason, exception); - // rejection msgs are always originated in the local silo, they are never remote. - this.ReceiveMessage(error); + msg.Dispose(); } } diff --git a/src/Orleans.Runtime/Networking/ConnectionListener.cs b/src/Orleans.Runtime/Networking/ConnectionListener.cs index 4f6cef82edc..692db722981 100644 --- a/src/Orleans.Runtime/Networking/ConnectionListener.cs +++ b/src/Orleans.Runtime/Networking/ConnectionListener.cs @@ -1,210 +1,223 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Net; +using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Orleans.Configuration; using Orleans.Core.Diagnostics; using Orleans.Internal; +using Orleans.Connections.Transport; +using Orleans.Connections; +using Orleans.Runtime.Internal; -namespace Orleans.Runtime.Messaging +namespace Orleans.Runtime.Messaging; + +internal abstract partial class ConnectionListener { - internal abstract partial class ConnectionListener + private readonly ConnectionManager _connectionManager; + private readonly ConnectionCommon _connectionShared; + private readonly MessageTransportListener[] _listeners; + private readonly ConcurrentDictionary _connections = new(ReferenceEqualsComparer.Default); + private readonly CancellationTokenSource _shutdownCancellation = new(); + private Task? _acceptLoopTask; + + protected ConnectionListener( + IEnumerable listeners, + IEnumerable middleware, + IOptions connectionOptions, + ConnectionManager connectionManager, + ConnectionCommon connectionShared) { - private readonly IConnectionListenerFactory listenerFactory; - private readonly ConnectionManager connectionManager; - protected readonly ConcurrentDictionary connections = new(ReferenceEqualsComparer.Default); - private readonly ConnectionCommon connectionShared; - private Task? acceptLoopTask; - private IConnectionListener? listener; -#if NET9_0_OR_GREATER - private readonly Lock connectionDelegateLock = new(); -#else - private readonly object connectionDelegateLock = new(); -#endif - private ConnectionDelegate? connectionDelegate; - - protected ConnectionListener( - IConnectionListenerFactory listenerFactory, - IOptions connectionOptions, - ConnectionManager connectionManager, - ConnectionCommon connectionShared) - { - this.listenerFactory = listenerFactory; - this.connectionManager = connectionManager; - this.ConnectionOptions = connectionOptions.Value; - this.connectionShared = connectionShared; - } - - public abstract EndPoint? Endpoint { get; } - - protected IServiceProvider ServiceProvider => this.connectionShared.ServiceProvider; - - protected ILogger Logger => this.connectionShared.Logger; - protected ConnectionOptions ConnectionOptions { get; } + // Get the listeners which are valid according to their configuration. + _listeners = GetListeners(listeners, middleware).ToArray(); + _connectionManager = connectionManager; + ConnectionOptions = connectionOptions.Value; + _connectionShared = connectionShared; - protected abstract Connection CreateConnection(ConnectionContext context); - - protected ConnectionDelegate ConnectionDelegate + static IEnumerable GetListeners(IEnumerable registered, IEnumerable middleware) { - get + // Filter out duplicates and non-valid listeners + var seen = new HashSet(StringComparer.Ordinal); + foreach (var listener in registered) { - if (this.connectionDelegate != null) return this.connectionDelegate; + if (!listener.IsValid) continue; + if (!seen.Add(listener.ListenerName)) continue; + var result = listener; - lock (this.connectionDelegateLock) + foreach (var mw in middleware) { - if (this.connectionDelegate != null) return this.connectionDelegate; - - // Configure the connection builder using the user-defined options. - var connectionBuilder = new ConnectionBuilder(this.ServiceProvider); - connectionBuilder.Use(next => - { - return context => - { - context.Features.Set(new UnderlyingConnectionTransportFeature { Transport = context.Transport }); - return next(context); - }; - }); - this.ConfigureConnectionBuilder(connectionBuilder); - Connection.ConfigureBuilder(connectionBuilder); - return this.connectionDelegate = connectionBuilder.Build(); + result = mw.Apply(result); } + + yield return result; } } + } + + protected bool HasListeners => _listeners is { Length: > 0 }; + + protected IServiceProvider ServiceProvider => _connectionShared.ServiceProvider; - protected virtual void ConfigureConnectionBuilder(IConnectionBuilder connectionBuilder) { } + protected ConnectionTrace TransportTrace => _connectionShared.ConnectionTrace; - protected async Task BindAsync() + protected ConnectionOptions ConnectionOptions { get; } + + protected abstract Connection CreateConnection(MessageTransport transport); + + protected async Task BindAsync(CancellationToken cancellationToken) + { + var tasks = new List(_listeners.Length); + foreach (var listener in _listeners) { - this.listener = await this.listenerFactory.BindAsync(this.Endpoint!); + tasks.Add(listener.BindAsync(cancellationToken).AsTask()); } - protected void Start() + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + protected void Start() + { + if (_listeners is { Length: 0 }) { - if (this.listener is null) throw new InvalidOperationException("Listener is not bound"); - acceptLoopTask = RunAcceptLoop(); + _acceptLoopTask = Task.CompletedTask; + return; } - private async Task RunAcceptLoop() + using var _ = new ExecutionContextSuppressor(); + var tasks = new List(_listeners.Length); + foreach (var listener in _listeners) { - await Task.Yield(); - try - { - while (true) - { - var context = await this.listener!.AcceptAsync(); - if (context == null) break; - - var connection = this.CreateConnection(context); - this.StartConnection(connection); - } - } - catch (Exception exception) - { - ConnectionEvents.EmitAcceptFailed(this.Endpoint!, exception); - LogCriticalExceptionInAcceptAsync(this.Logger, exception); - } + tasks.Add(RunAcceptLoop(listener)); } - protected async Task StopAsync(CancellationToken cancellationToken) + _acceptLoopTask = Task.WhenAll(tasks); + } + + private async Task RunAcceptLoop(MessageTransportListener listener) + { + await Task.Yield(); + try { - try + while (true) { - await listener!.UnbindAsync(cancellationToken); - - if (acceptLoopTask is not null) - { - await acceptLoopTask; - } + var context = await listener.AcceptAsync(_shutdownCancellation.Token).ConfigureAwait(false); + if (context == null) break; - var closeTasks = new List(); - foreach (var kv in connections) + Connection connection; + try { - closeTasks.Add(kv.Key.CloseAsync(exception: null)); + connection = CreateConnection(context); } - - if (closeTasks.Count > 0) + catch (Exception exception) { - await Task.WhenAll(closeTasks).WaitAsync(cancellationToken).SuppressThrowing(); + LogConnectionCreationFailure(TransportTrace, exception, listener); + await context.DisposeAsync().ConfigureAwait(false); + continue; } - await this.connectionManager.Closed; - await this.listener!.DisposeAsync(); - } - catch (Exception exception) - { - LogWarningExceptionDuringShutdown(this.Logger, exception); + StartConnection(connection); } } + catch (Exception exception) + { + LogAcceptFailure(TransportTrace, exception, listener); + } + } - private void StartConnection(Connection connection) + protected async Task StopAsync(CancellationToken cancellationToken) + { + try { - connections.TryAdd(connection, null); + if (!HasListeners) + { + return; + } + + await Task.WhenAll(_listeners.Select(listener => listener.UnbindAsync(cancellationToken).AsTask())).ConfigureAwait(false); + await _shutdownCancellation.CancelAsync().ConfigureAwait(false); - ThreadPool.UnsafeQueueUserWorkItem(state => + if (_acceptLoopTask is not null) { - var (t, connection) = ((ConnectionListener, Connection))state!; - t.RunConnectionAsync(connection).Ignore(); - }, (this, connection)); - } + await _acceptLoopTask; + } - private async Task RunConnectionAsync(Connection connection) - { - using (this.BeginConnectionScope(connection)) + var closeTasks = new List(); + foreach (var kv in _connections) { - try - { - await connection.Run(); - LogInformationConnectionTerminated(this.Logger, connection); - } - catch (Exception exception) - { - LogInformationConnectionTerminatedWithException(this.Logger, exception, connection); - } - finally - { - this.connections.TryRemove(connection, out _); - } + closeTasks.Add(kv.Key.CloseAsync(exception: null)); + } + + if (closeTasks.Count > 0) + { + await Task.WhenAny(Task.WhenAll(closeTasks), cancellationToken.WhenCancelled()); } + + await _connectionManager.Closed; + await Task.WhenAll(_listeners.Select(listener => listener.DisposeAsync().AsTask())); } + catch (Exception exception) + { + LogShutdownFailure(TransportTrace, exception); + } + } - private IDisposable? BeginConnectionScope(Connection connection) + private void StartConnection(Connection connection) + { + _connections.TryAdd(connection, null); + + ThreadPool.UnsafeQueueUserWorkItem(state => + { + var (t, connection) = ((ConnectionListener, Connection))state!; + t.RunConnectionAsync(connection).Ignore(); + }, (this, connection)); + } + + private async Task RunConnectionAsync(Connection connection) + { + using (BeginConnectionScope(connection)) { - if (this.Logger.IsEnabled(LogLevel.Critical)) + try { - return this.Logger.BeginScope(new ConnectionLogScope(connection)); + await connection.RunAsync(); + LogConnectionTerminated(TransportTrace, connection); } + catch (Exception exception) + { + LogConnectionTerminatedWithException(TransportTrace, exception, connection); + } + finally + { + _connections.TryRemove(connection, out _); + } + } + } - return null; + private IDisposable? BeginConnectionScope(Connection connection) + { + if (TransportTrace.IsEnabled(LogLevel.Critical)) + { + return TransportTrace.BeginScope(new ConnectionLogScope(connection)); } - [LoggerMessage( - Level = LogLevel.Critical, - Message = "Exception in AcceptAsync" - )] - private static partial void LogCriticalExceptionInAcceptAsync(ILogger logger, Exception exception); - - [LoggerMessage( - Level = LogLevel.Warning, - Message = "Exception during shutdown" - )] - private static partial void LogWarningExceptionDuringShutdown(ILogger logger, Exception exception); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Connection {Connection} terminated" - )] - private static partial void LogInformationConnectionTerminated(ILogger logger, Connection connection); - - [LoggerMessage( - Level = LogLevel.Information, - Message = "Connection {Connection} terminated with an exception" - )] - private static partial void LogInformationConnectionTerminatedWithException(ILogger logger, Exception exception, Connection connection); + return null; } + + [LoggerMessage(Level = LogLevel.Critical, Message = "Exception in AcceptAsync for listener {Listener}")] + private static partial void LogAcceptFailure(ILogger logger, Exception exception, MessageTransportListener listener); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Exception creating a connection for listener {Listener}")] + private static partial void LogConnectionCreationFailure(ILogger logger, Exception exception, MessageTransportListener listener); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Exception during shutdown")] + private static partial void LogShutdownFailure(ILogger logger, Exception exception); + + [LoggerMessage(Level = LogLevel.Information, Message = "Connection {Connection} terminated")] + private static partial void LogConnectionTerminated(ILogger logger, Connection connection); + + [LoggerMessage(Level = LogLevel.Information, Message = "Connection {Connection} terminated with an exception")] + private static partial void LogConnectionTerminatedWithException(ILogger logger, Exception exception, Connection connection); } diff --git a/src/Orleans.Runtime/Networking/GatewayConnectionListener.cs b/src/Orleans.Runtime/Networking/GatewayConnectionListener.cs index 6387d45f4fc..87d6585a47e 100644 --- a/src/Orleans.Runtime/Networking/GatewayConnectionListener.cs +++ b/src/Orleans.Runtime/Networking/GatewayConnectionListener.cs @@ -1,88 +1,62 @@ using System; -using System.Net; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Orleans.Configuration; +using Orleans.Connections.Transport; namespace Orleans.Runtime.Messaging { - internal sealed class GatewayConnectionListener : ConnectionListener, ILifecycleParticipant, ILifecycleObserver + internal sealed class GatewayConnectionListener( + IEnumerable listeners, + IEnumerable listenerMiddleware, + IOptions connectionOptions, + OverloadDetector overloadDetector, + ILocalSiloDetails localSiloDetails, + MessageCenter messageCenter, + ConnectionManager connectionManager, + ConnectionCommon connectionShared, + ConnectionPreambleHelper connectionPreambleHelper) : ConnectionListener( + listeners.Where(static listener => listener.ListenerName.Equals(DefaultListenerName, StringComparison.Ordinal)), + listenerMiddleware, + connectionOptions, + connectionManager, + connectionShared), ILifecycleParticipant, ILifecycleObserver { - internal static readonly object ServicesKey = new object(); - private readonly ILocalSiloDetails localSiloDetails; - private readonly MessageCenter messageCenter; - private readonly ConnectionCommon connectionShared; - private readonly ConnectionPreambleHelper connectionPreambleHelper; - private readonly ILogger logger; - private readonly EndpointOptions endpointOptions; - private readonly SiloConnectionOptions siloConnectionOptions; - private readonly OverloadDetector overloadDetector; - private readonly Gateway? gateway; - - public GatewayConnectionListener( - IServiceProvider serviceProvider, - IOptions connectionOptions, - IOptions siloConnectionOptions, - OverloadDetector overloadDetector, - ILocalSiloDetails localSiloDetails, - IOptions endpointOptions, - MessageCenter messageCenter, - ConnectionManager connectionManager, - ConnectionCommon connectionShared, - ConnectionPreambleHelper connectionPreambleHelper, - ILogger logger) - : base(serviceProvider.GetRequiredKeyedService(ServicesKey), connectionOptions, connectionManager, connectionShared) - { - this.siloConnectionOptions = siloConnectionOptions.Value; - this.overloadDetector = overloadDetector; - this.gateway = messageCenter.Gateway; - this.localSiloDetails = localSiloDetails; - this.messageCenter = messageCenter; - this.connectionShared = connectionShared; - this.connectionPreambleHelper = connectionPreambleHelper; - this.logger = logger; - this.endpointOptions = endpointOptions.Value; - } - - public override EndPoint? Endpoint => this.endpointOptions.GetListeningProxyEndpoint(); - - protected override Connection CreateConnection(ConnectionContext context) + public const string DefaultListenerName = "gateway"; + private readonly ILocalSiloDetails _localSiloDetails = localSiloDetails; + private readonly MessageCenter _messageCenter = messageCenter; + private readonly ConnectionCommon _connectionShared = connectionShared; + private readonly ConnectionPreambleHelper _connectionPreambleHelper = connectionPreambleHelper; + private readonly OverloadDetector _overloadDetector = overloadDetector; + private readonly Gateway? _gateway = messageCenter.Gateway; + + protected override Connection CreateConnection(MessageTransport transport) { + var gateway = _gateway!; return new GatewayInboundConnection( - context, - this.ConnectionDelegate, - this.gateway!, - this.overloadDetector, - this.localSiloDetails, - this.ConnectionOptions, - this.messageCenter, - this.connectionShared, - this.connectionPreambleHelper, - this.gateway!.GatewayInstruments); - } - - protected override void ConfigureConnectionBuilder(IConnectionBuilder connectionBuilder) - { - var configureDelegate = (SiloConnectionOptions.ISiloConnectionBuilderOptions)this.siloConnectionOptions; - configureDelegate.ConfigureGatewayInboundBuilder(connectionBuilder); - base.ConfigureConnectionBuilder(connectionBuilder); + transport, + gateway, + _overloadDetector, + _localSiloDetails, + ConnectionOptions, + _messageCenter, + _connectionShared, + _connectionPreambleHelper, + gateway.GatewayInstruments); } void ILifecycleParticipant.Participate(ISiloLifecycle lifecycle) { - if (this.Endpoint is null) return; + if (!HasListeners) return; lifecycle.Subscribe(nameof(GatewayConnectionListener), ServiceLifecycleStage.RuntimeInitialize - 1, this); - lifecycle.Subscribe(nameof(GatewayConnectionListener), ServiceLifecycleStage.Active, RunStart); - - Task RunStart(CancellationToken ct) => Task.Run(Start, CancellationToken.None); + lifecycle.Subscribe(nameof(GatewayConnectionListener), ServiceLifecycleStage.Active, cancellationToken => Task.Run(Start, cancellationToken)); } - Task ILifecycleObserver.OnStart(CancellationToken ct) => Task.Run(BindAsync, CancellationToken.None); + Task ILifecycleObserver.OnStart(CancellationToken ct) => Task.Run(() => BindAsync(ct), ct); Task ILifecycleObserver.OnStop(CancellationToken ct) => Task.Run(() => StopAsync(ct), CancellationToken.None); } } diff --git a/src/Orleans.Runtime/Networking/GatewayInboundConnection.cs b/src/Orleans.Runtime/Networking/GatewayInboundConnection.cs index de4102e102c..0dbaa038bbc 100644 --- a/src/Orleans.Runtime/Networking/GatewayInboundConnection.cs +++ b/src/Orleans.Runtime/Networking/GatewayInboundConnection.cs @@ -1,10 +1,10 @@ using System; using System.Text; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; using Microsoft.Extensions.Logging; using Orleans.Configuration; using Orleans.Messaging; +using Orleans.Connections.Transport; namespace Orleans.Runtime.Messaging { @@ -20,8 +20,7 @@ internal sealed partial class GatewayInboundConnection : Connection private readonly string myClusterId; public GatewayInboundConnection( - ConnectionContext connection, - ConnectionDelegate middleware, + MessageTransport transport, Gateway gateway, OverloadDetector overloadDetector, ILocalSiloDetails siloDetails, @@ -30,7 +29,7 @@ public GatewayInboundConnection( ConnectionCommon connectionShared, ConnectionPreambleHelper connectionPreambleHelper, GatewayInstruments gatewayInstruments) - : base(connection, middleware, connectionShared) + : base(transport, connectionShared) { this.connectionOptions = connectionOptions; this.gateway = gateway; @@ -44,41 +43,45 @@ public GatewayInboundConnection( protected override ConnectionDirection ConnectionDirection => ConnectionDirection.GatewayToClient; - protected override IMessageCenter MessageCenter => this.messageCenter; + protected override TimeSpan CloseConnectionTimeout => this.connectionOptions.CloseConnectionTimeout; - protected override void RecordMessageReceive(Message msg, int numTotalBytes, int headerBytes) + protected override MessageCenter MessageCenter => this.messageCenter; + + internal protected override void RecordMessageReceive(Message message, int totalBytes, int headerBytes) { - MessagingInstrumentation.OnMessageReceive(msg, numTotalBytes, headerBytes, ConnectionDirection); - this.gatewayInstruments.OnGatewayReceived(); + MessagingMetrics.OnMessageReceive(message, totalBytes, headerBytes, ConnectionDirection); + gatewayInstruments.OnGatewayReceived(); } - protected override void RecordMessageSend(Message msg, int numTotalBytes, int headerBytes) + internal protected override void RecordMessageSend(Message message, int totalBytes, int headerBytes) { - MessagingInstrumentation.OnMessageSend(msg, numTotalBytes, headerBytes, ConnectionDirection); - this.gatewayInstruments.OnGatewaySent(); + MessagingMetrics.OnMessageSend(message, totalBytes, headerBytes, ConnectionDirection); + gatewayInstruments.OnGatewaySent(); } - protected override void OnReceivedMessage(Message msg) + protected internal override void OnReceivedMessage(Message msg) { // Don't process messages that have already timed out if (msg.IsExpired) { this.MessagingTrace.OnDropExpiredMessage(msg, MessagingInstruments.Phase.Receive); + msg.Dispose(); return; } // Are we overloaded? if (this.overloadDetector.IsOverloaded) { - MessagingInstrumentation.OnRejectedMessage(msg); + MessagingMetrics.OnRejectedMessage(msg); Message rejection = this.MessageFactory.CreateRejectionResponse(msg, Message.RejectionTypes.GatewayTooBusy, "Shedding load"); this.messageCenter.TryDeliverToProxy(rejection); LogRejectingRequestDueToOverloading(this.Log, msg); this.gatewayInstruments.OnGatewayLoadShedding(); + msg.Dispose(); return; } - SiloAddress? targetAddress = this.gateway.TryToReroute(msg); + var targetAddress = this.gateway.TryToReroute(msg); msg.SendingSilo = this.myAddress; if (targetAddress is null) { @@ -91,7 +94,7 @@ protected override void OnReceivedMessage(Message msg) msg.TargetGrain = systemTargetId.WithSiloAddress(this.myAddress).GrainId; } - MessagingInstrumentation.OnMessageReRoute(msg); + MessagingMetrics.OnMessageReRoute(msg); this.messageCenter.RerouteMessage(msg); } else @@ -108,7 +111,7 @@ protected override void OnReceivedMessage(Message msg) } } - protected override async Task RunInternal() + protected override async Task RunAsyncCore() { var preamble = await connectionPreambleHelper.Read(this.Context); @@ -135,7 +138,7 @@ await connectionPreambleHelper.Write( try { this.gateway.RecordOpenedConnection(this, clientId); - await base.RunInternal(); + await base.RunAsyncCore(); } finally { @@ -149,6 +152,7 @@ protected override bool PrepareMessageForSend(Message msg) if (msg.IsExpired) { this.MessagingTrace.OnDropExpiredMessage(msg, MessagingInstruments.Phase.Send); + msg.Dispose(); return false; } @@ -160,7 +164,7 @@ protected override bool PrepareMessageForSend(Message msg) public void FailMessage(Message msg, string reason) { - MessagingInstrumentation.OnFailedSentMessage(msg); + MessagingMetrics.OnFailedSentMessage(msg); if (msg.Direction == Message.Directions.Request) { LogSiloRejectingMessage(this.Log, this.myAddress, msg, reason); @@ -175,7 +179,8 @@ public void FailMessage(Message msg, string reason) else { LogSiloDroppingMessage(this.Log, this.myAddress, msg, reason); - MessagingInstrumentation.OnDroppedSentMessage(msg); + MessagingMetrics.OnDroppedSentMessage(msg); + msg.Dispose(); } } @@ -201,11 +206,6 @@ protected override void RetryMessage(Message msg, Exception? ex = null) } } - protected override void OnSendMessageFailure(Message message, string error) - { - this.FailMessage(message, error); - } - [LoggerMessage( Level = LogLevel.Debug, Message = "Rejecting a request due to overloading: {Message}" diff --git a/src/Orleans.Runtime/Networking/SiloConnection.cs b/src/Orleans.Runtime/Networking/SiloConnection.cs index 7853a97bbbf..1feabe96d3a 100644 --- a/src/Orleans.Runtime/Networking/SiloConnection.cs +++ b/src/Orleans.Runtime/Networking/SiloConnection.cs @@ -4,11 +4,11 @@ using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Orleans.Configuration; using Orleans.Messaging; +using Orleans.Connections.Transport; using Orleans.Serialization.Invocation; namespace Orleans.Runtime.Messaging @@ -24,8 +24,7 @@ internal sealed partial class SiloConnection : Connection public SiloConnection( SiloAddress? remoteSiloAddress, - ConnectionContext connection, - ConnectionDelegate middleware, + MessageTransport transport, MessageCenter messageCenter, ILocalSiloDetails localSiloDetails, ConnectionManager connectionManager, @@ -33,7 +32,7 @@ public SiloConnection( ConnectionCommon connectionShared, ProbeRequestMonitor probeMonitor, ConnectionPreambleHelper connectionPreambleHelper) - : base(connection, middleware, connectionShared) + : base(transport, connectionShared) { this.messageCenter = messageCenter; this.connectionManager = connectionManager; @@ -53,24 +52,30 @@ public SiloConnection( protected override ConnectionDirection ConnectionDirection => ConnectionDirection.SiloToSilo; - protected override IMessageCenter MessageCenter => this.messageCenter; + protected override TimeSpan CloseConnectionTimeout => this.connectionOptions.CloseConnectionTimeout; - protected override void RecordMessageReceive(Message msg, int numTotalBytes, int headerBytes) - { - MessagingInstrumentation.OnMessageReceive(msg, numTotalBytes, headerBytes, ConnectionDirection, RemoteSiloAddress); - } + protected override MessageCenter MessageCenter => this.messageCenter; - protected override void RecordMessageSend(Message msg, int numTotalBytes, int headerBytes) - { - MessagingInstrumentation.OnMessageSend(msg, numTotalBytes, headerBytes, ConnectionDirection, RemoteSiloAddress); - } + internal protected override void RecordMessageReceive(Message message, int totalBytes, int headerBytes) => + MessagingMetrics.OnMessageReceive(message, totalBytes, headerBytes, ConnectionDirection, RemoteSiloAddress); + + internal protected override void RecordMessageSend(Message message, int totalBytes, int headerBytes) => + MessagingMetrics.OnMessageSend(message, totalBytes, headerBytes, ConnectionDirection, RemoteSiloAddress); - protected override void OnReceivedMessage(Message msg) + protected internal override void OnReceivedMessage(Message msg) { // See it's a Ping message, and if so, short-circuit it if (msg.IsPing()) { - this.HandlePingMessage(msg); + try + { + this.HandlePingMessage(msg); + } + finally + { + msg.Dispose(); + } + return; } @@ -81,17 +86,18 @@ protected override void OnReceivedMessage(Message msg) if (msg.IsExpired) { this.MessagingTrace.OnDropExpiredMessage(msg, MessagingInstruments.Phase.Receive); + msg.Dispose(); return; } - // If we've stopped application message processing, then reject requests and filter out other messages. + // Reject application requests with targeted cache invalidation during shutdown. // Note that if we identify or add other grains that are required for proper stopping, we will need to treat them as we do the membership table grain here. if (messageCenter.IsBlockingApplicationMessages && !msg.IsSystemMessage) { - // We reject new requests with targeted cache invalidation and drop all other messages. if (msg.Direction != Message.Directions.Request) { this.MessagingTrace.OnDropBlockedApplicationMessage(msg); + msg.Dispose(); return; } @@ -124,7 +130,7 @@ protected override void OnReceivedMessage(Message msg) // (if it was a request), or drop it on the floor if it was a response or one-way. if (msg.Direction == Message.Directions.Request) { - MessagingInstrumentation.OnRejectedMessage(msg); + MessagingMetrics.OnRejectedMessage(msg); var rejection = this.MessageFactory.CreateRejectionResponse( msg, Message.RejectionTypes.Transient, @@ -145,11 +151,13 @@ protected override void OnReceivedMessage(Message msg) LogDebugRejectingObsoleteRequest(this.Log, targetSilo, siloAddress, msg); } } + + msg.Dispose(); } private void HandlePingMessage(Message msg) { - MessagingInstrumentation.OnPingReceive(msg.SendingSilo!); + MessagingMetrics.OnPingReceive(msg.SendingSilo!); var objectId = RuntimeHelpers.GetHashCode(msg); LogTraceRespondingToPing(this.Log, msg.SendingSilo!, objectId, msg); @@ -157,7 +165,7 @@ private void HandlePingMessage(Message msg) if (!this.LocalSiloAddress.Equals(msg.TargetSilo)) { // Got ping that is not destined to me. For example, got a ping to my older incarnation. - MessagingInstrumentation.OnRejectedMessage(msg); + MessagingMetrics.OnRejectedMessage(msg); Message rejection = this.MessageFactory.CreateRejectionResponse(msg, Message.RejectionTypes.Unrecoverable, $"The target silo is no longer active: target was {msg.TargetSilo}, but this silo is {LocalSiloAddress}. The rejected ping message is {msg}."); this.Send(rejection); @@ -171,23 +179,13 @@ private void HandlePingMessage(Message msg) } } - protected override void OnSendMessageFailure(Message message, string error) - { - if (message.IsPing()) - { - LogWarningFailedToSendPingMessage(this.Log, message); - } - - this.FailMessage(message, error); - } - - protected override async Task RunInternal() + protected override async Task RunAsyncCore() { Exception? error = default; try { await Task.WhenAll(ReadPreamble(), WritePreamble()); - await base.RunInternal(); + await base.RunAsyncCore(); } catch (Exception exception) when ((error = exception) is null) { @@ -248,6 +246,7 @@ protected override bool PrepareMessageForSend(Message msg) LogWarningDroppingExpiredPingMessage(this.Log, msg); } + msg.Dispose(); return false; } @@ -274,7 +273,7 @@ public void FailMessage(Message msg, string reason) LogWarningFailedPingMessage(this.Log, msg); } - MessagingInstrumentation.OnFailedSentMessage(msg); + MessagingMetrics.OnFailedSentMessage(msg); if (msg.Direction == Message.Directions.Request) { LogDebugSiloRejectingMessage(this.Log, this.LocalSiloAddress, msg, reason); @@ -289,6 +288,7 @@ public void FailMessage(Message msg, string reason) else { this.MessagingTrace.OnSiloDropSendingMessage(this.LocalSiloAddress, msg, reason); + msg.Dispose(); } } @@ -335,12 +335,6 @@ protected override void RetryMessage(Message msg, Exception? ex = null) )] private static partial void LogTraceRespondingToPing(ILogger logger, SiloAddress silo, int objectId, Message message); - [LoggerMessage( - Level = LogLevel.Warning, - Message = "Failed to send ping message {Message}" - )] - private static partial void LogWarningFailedToSendPingMessage(ILogger logger, Message message); - [LoggerMessage( Level = LogLevel.Warning, Message = "Dropping expired ping message {Message}" diff --git a/src/Orleans.Runtime/Networking/SiloConnectionFactory.cs b/src/Orleans.Runtime/Networking/SiloConnectionFactory.cs index b54b21de802..8bb74a15883 100644 --- a/src/Orleans.Runtime/Networking/SiloConnectionFactory.cs +++ b/src/Orleans.Runtime/Networking/SiloConnectionFactory.cs @@ -1,55 +1,41 @@ using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Net; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Orleans.Configuration; +using Orleans.Connections.Transport; namespace Orleans.Runtime.Messaging { - internal sealed class SiloConnectionFactory : ConnectionFactory + internal sealed class SiloConnectionFactory( + IServiceProvider serviceProvider, + IOptions connectionOptions, + MessageTransportConnector connector, + IEnumerable connectorMiddleware, + ILocalSiloDetails localSiloDetails, + ConnectionCommon connectionShared, + ProbeRequestMonitor probeRequestMonitor, + ConnectionPreambleHelper connectionPreambleHelper) : ConnectionFactory(connector, connectorMiddleware) { - internal static readonly object ServicesKey = new object(); - private readonly ILocalSiloDetails localSiloDetails; - private readonly ConnectionCommon connectionShared; - private readonly ProbeRequestMonitor probeRequestMonitor; - private readonly ConnectionPreambleHelper connectionPreambleHelper; - private readonly IServiceProvider serviceProvider; - private readonly SiloConnectionOptions siloConnectionOptions; -#if NET9_0_OR_GREATER - private readonly Lock initializationLock = new(); -#else - private readonly object initializationLock = new(); -#endif - private bool isInitialized; - private ConnectionManager connectionManager = null!; - private MessageCenter messageCenter = null!; - private ISiloStatusOracle siloStatusOracle = null!; - - public SiloConnectionFactory( - IServiceProvider serviceProvider, - IOptions connectionOptions, - IOptions siloConnectionOptions, - ILocalSiloDetails localSiloDetails, - ConnectionCommon connectionShared, - ProbeRequestMonitor probeRequestMonitor, - ConnectionPreambleHelper connectionPreambleHelper) - : base(serviceProvider.GetRequiredKeyedService(ServicesKey), serviceProvider, connectionOptions) - { - this.serviceProvider = serviceProvider; - this.siloConnectionOptions = siloConnectionOptions.Value; - this.localSiloDetails = localSiloDetails; - this.connectionShared = connectionShared; - this.probeRequestMonitor = probeRequestMonitor; - this.connectionPreambleHelper = connectionPreambleHelper; - } + private readonly ILocalSiloDetails _localSiloDetails = localSiloDetails; + private readonly ConnectionCommon _connectionShared = connectionShared; + private readonly ProbeRequestMonitor _probeRequestMonitor = probeRequestMonitor; + private readonly ConnectionPreambleHelper _connectionPreambleHelper = connectionPreambleHelper; + private readonly ConnectionOptions _connectionOptions = connectionOptions.Value; + private readonly IServiceProvider _serviceProvider = serviceProvider; + private readonly object _initializationLock = new(); + private bool _isInitialized; + private ConnectionManager? _connectionManager; + private MessageCenter? _messageCenter; + private ClusterMembershipService? _clusterMembership; public override ValueTask ConnectAsync(SiloAddress address, CancellationToken cancellationToken) { EnsureInitialized(); - - if (this.siloStatusOracle.IsDeadSilo(address)) + if (_clusterMembership.CurrentSnapshot.GetSiloStatus(address) == SiloStatus.Dead) { throw new ConnectionAbortedException($"Denying connection to known-dead silo {address}"); } @@ -57,45 +43,44 @@ public override ValueTask ConnectAsync(SiloAddress address, Cancella return base.ConnectAsync(address, cancellationToken); } - protected override Connection CreateConnection(SiloAddress address, ConnectionContext context) + protected override Connection CreateConnection(SiloAddress address, MessageTransport transport) { EnsureInitialized(); return new SiloConnection( address, - context, - this.ConnectionDelegate, - this.messageCenter, - this.localSiloDetails, - this.connectionManager, - this.ConnectionOptions, - this.connectionShared, - this.probeRequestMonitor, - this.connectionPreambleHelper); + transport, + _messageCenter, + _localSiloDetails, + _connectionManager, + _connectionOptions, + _connectionShared, + _probeRequestMonitor, + _connectionPreambleHelper); } - protected override void ConfigureConnectionBuilder(IConnectionBuilder connectionBuilder) - { - var configureDelegate = (SiloConnectionOptions.ISiloConnectionBuilderOptions)this.siloConnectionOptions; - configureDelegate.ConfigureSiloOutboundBuilder(connectionBuilder); - base.ConfigureConnectionBuilder(connectionBuilder); - } + protected override EndPoint GetEndPoint(SiloAddress address) => address.Endpoint; + [MemberNotNull(nameof(_messageCenter), nameof(_connectionManager), nameof(_clusterMembership))] private void EnsureInitialized() { - if (!isInitialized) + if (!_isInitialized) { - lock (this.initializationLock) + lock (_initializationLock) { - if (!isInitialized) + if (!_isInitialized) { - this.messageCenter = this.serviceProvider.GetRequiredService(); - this.connectionManager = this.serviceProvider.GetRequiredService(); - this.siloStatusOracle = this.serviceProvider.GetRequiredService(); - this.isInitialized = true; + _messageCenter = _serviceProvider.GetRequiredService(); + _connectionManager = _serviceProvider.GetRequiredService(); + _clusterMembership = _serviceProvider.GetRequiredService(); + _isInitialized = true; } } } + + Debug.Assert(_messageCenter is not null); + Debug.Assert(_connectionManager is not null); + Debug.Assert(_clusterMembership is not null); } } } diff --git a/src/Orleans.Runtime/Networking/SiloConnectionListener.cs b/src/Orleans.Runtime/Networking/SiloConnectionListener.cs index c92c902481c..82d6208006a 100644 --- a/src/Orleans.Runtime/Networking/SiloConnectionListener.cs +++ b/src/Orleans.Runtime/Networking/SiloConnectionListener.cs @@ -1,86 +1,66 @@ using System; -using System.Net; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Orleans.Configuration; +using Orleans.Connections.Transport; namespace Orleans.Runtime.Messaging { - internal sealed class SiloConnectionListener : ConnectionListener, ILifecycleParticipant, ILifecycleObserver + internal sealed class SiloConnectionListener( + IEnumerable listeners, + IEnumerable listenerMiddleware, + IOptions connectionOptions, + MessageCenter messageCenter, + ILocalSiloDetails localSiloDetails, + ConnectionManager connectionManager, + ConnectionCommon connectionShared, + ProbeRequestMonitor probeRequestMonitor, + ConnectionPreambleHelper connectionPreambleHelper) : ConnectionListener( + listeners.Where(static listener => listener.ListenerName.Equals(DefaultListenerName, StringComparison.Ordinal)), + listenerMiddleware, + connectionOptions, + connectionManager, + connectionShared), ILifecycleParticipant, ILifecycleObserver { - internal static readonly object ServicesKey = new object(); - private readonly ILocalSiloDetails localSiloDetails; - private readonly SiloConnectionOptions siloConnectionOptions; - private readonly MessageCenter messageCenter; - private readonly EndpointOptions endpointOptions; - private readonly ConnectionManager connectionManager; - private readonly ConnectionCommon connectionShared; - private readonly ProbeRequestMonitor probeRequestMonitor; - private readonly ConnectionPreambleHelper connectionPreambleHelper; + public const string DefaultListenerName = "silo"; + private readonly ILocalSiloDetails _localSiloDetails = localSiloDetails; + private readonly MessageCenter _messageCenter = messageCenter; + private readonly ConnectionManager _connectionManager = connectionManager; + private readonly ConnectionCommon _connectionShared = connectionShared; + private readonly ProbeRequestMonitor _probeRequestMonitor = probeRequestMonitor; + private readonly ConnectionPreambleHelper _connectionPreambleHelper = connectionPreambleHelper; - public SiloConnectionListener( - IServiceProvider serviceProvider, - IOptions connectionOptions, - IOptions siloConnectionOptions, - MessageCenter messageCenter, - IOptions endpointOptions, - ILocalSiloDetails localSiloDetails, - ConnectionManager connectionManager, - ConnectionCommon connectionShared, - ProbeRequestMonitor probeRequestMonitor, - ConnectionPreambleHelper connectionPreambleHelper) - : base(serviceProvider.GetRequiredKeyedService(ServicesKey), connectionOptions, connectionManager, connectionShared) - { - this.siloConnectionOptions = siloConnectionOptions.Value; - this.messageCenter = messageCenter; - this.localSiloDetails = localSiloDetails; - this.connectionManager = connectionManager; - this.connectionShared = connectionShared; - this.probeRequestMonitor = probeRequestMonitor; - this.connectionPreambleHelper = connectionPreambleHelper; - this.endpointOptions = endpointOptions.Value; - } - - public override EndPoint? Endpoint => this.endpointOptions.GetListeningSiloEndpoint(); - - protected override Connection CreateConnection(ConnectionContext context) + protected override Connection CreateConnection(MessageTransport transport) { return new SiloConnection( default, - context, - this.ConnectionDelegate, - this.messageCenter, - this.localSiloDetails, - this.connectionManager, - this.ConnectionOptions, - this.connectionShared, - this.probeRequestMonitor, - this.connectionPreambleHelper); - } - - protected override void ConfigureConnectionBuilder(IConnectionBuilder connectionBuilder) - { - var configureDelegate = (SiloConnectionOptions.ISiloConnectionBuilderOptions)this.siloConnectionOptions; - configureDelegate.ConfigureSiloInboundBuilder(connectionBuilder); - base.ConfigureConnectionBuilder(connectionBuilder); + transport, + _messageCenter, + _localSiloDetails, + _connectionManager, + ConnectionOptions, + _connectionShared, + _probeRequestMonitor, + _connectionPreambleHelper); } void ILifecycleParticipant.Participate(ISiloLifecycle lifecycle) { - if (this.Endpoint is null) return; + if (!HasListeners) return; lifecycle.Subscribe(nameof(SiloConnectionListener), ServiceLifecycleStage.RuntimeInitialize - 1, this); } Task ILifecycleObserver.OnStart(CancellationToken ct) => Task.Run(async () => { - await BindAsync(); + await BindAsync(ct); + // Start accepting connections immediately. Start(); - }, CancellationToken.None); + }, ct); Task ILifecycleObserver.OnStop(CancellationToken ct) => Task.Run(() => StopAsync(ct), CancellationToken.None); } diff --git a/src/Orleans.Serialization/Buffers/ArcBufferWriter.cs b/src/Orleans.Serialization/Buffers/ArcBufferWriter.cs index a91cf5bc7ba..0c565b9b761 100644 --- a/src/Orleans.Serialization/Buffers/ArcBufferWriter.cs +++ b/src/Orleans.Serialization/Buffers/ArcBufferWriter.cs @@ -40,10 +40,7 @@ public sealed class ArcBufferWriter : IBufferWriter, IDisposable // The total length of the buffer. private int _totalLength; - // Indicates whether the writer has been disposed. - private bool _disposed; - - // Indicates that this writer directly references pages pinned from another buffer. + // Pinned pages can be read and disposed but not mutated. private bool _hasPinnedPages; /// @@ -64,23 +61,17 @@ public ArcBufferWriter() /// /// Gets the number of unconsumed bytes. /// - public int Length - { - get - { - ThrowIfDisposed(); - return _totalLength - _readIndex; - } - } + public int UnconsumedLength => _totalLength - _readIndex; /// - /// Gets the reader for this writer. + /// Gets the number of unconsumed bytes (alias for UnconsumedLength). /// - public ArcBufferReader Reader - { - [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => new(this); - } + public int Length => UnconsumedLength; + + /// + /// Gets a reader over the unconsumed bytes. + /// + public ArcBufferReader Reader => new(this); /// /// Adds additional buffers to the destination list until the list has reached its capacity. @@ -89,7 +80,6 @@ public ArcBufferReader Reader [MethodImpl(MethodImplOptions.AggressiveInlining)] public void ReplenishBuffers(List> buffers) { - ThrowIfDisposed(); ThrowIfPinnedPages(); // Skip half-full pages in an attempt to minimize the number of buffers added to the destination @@ -120,14 +110,8 @@ public void ReplenishBuffers(List> buffers) [MethodImpl(MethodImplOptions.AggressiveInlining)] public void AdvanceWriter(int count) { - ThrowIfDisposed(); ThrowIfPinnedPages(); -#if NET5_0_OR_GREATER - ArgumentOutOfRangeException.ThrowIfLessThan(count, 0); -#else - if (count < 0) throw new ArgumentOutOfRangeException(nameof(count), "Length must be greater than or equal to 0."); -#endif _totalLength += count; while (true) { @@ -147,115 +131,21 @@ public void AdvanceWriter(int count) } /// - /// Overwrites bytes which have already been written to this buffer without changing the writer position. + /// Resets this instance, returning all memory. /// - /// The offset into the unconsumed bytes at which to start writing. - /// The bytes to write. - public void WriteAt(int offset, ReadOnlySpan value) - { - ThrowIfDisposed(); - ThrowIfPinnedPages(); - -#if NET5_0_OR_GREATER - ArgumentOutOfRangeException.ThrowIfLessThan(offset, 0); - ArgumentOutOfRangeException.ThrowIfGreaterThan(offset, Length); - ArgumentOutOfRangeException.ThrowIfGreaterThan(value.Length, Length - offset); -#else - if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset), "Offset must be greater than or equal to 0."); - if (offset > Length) throw new ArgumentOutOfRangeException(nameof(offset), "Offset must be less than or equal to the unconsumed length of the buffer."); - if (value.Length > Length - offset) throw new ArgumentOutOfRangeException(nameof(value), "Value must fit within the unconsumed length of the buffer."); -#endif - - if (value.IsEmpty) - { - return; - } - - var page = _readPage; - var pageOffset = _readIndex + offset; - while (pageOffset >= page.Length) - { - pageOffset -= page.Length; - page = page.Next!; - Debug.Assert(page is not null); - } - - while (!value.IsEmpty) - { - var destination = page.AsSpan(pageOffset, page.Length - pageOffset); - var copyLength = Math.Min(destination.Length, value.Length); - value[..copyLength].CopyTo(destination); - value = value[copyLength..]; - - if (value.IsEmpty) - { - return; - } + public void Reset() => Reset(0); - page = page.Next!; - Debug.Assert(page is not null); - pageOffset = 0; - } - } - - /// - /// Truncates this buffer to the specified unconsumed length. - /// - /// The new length of the unconsumed bytes. - public void Truncate(int length) + internal void Reset(int minimumPageSize) { - ThrowIfDisposed(); - ThrowIfPinnedPages(); - -#if NET5_0_OR_GREATER - ArgumentOutOfRangeException.ThrowIfLessThan(length, 0); - ArgumentOutOfRangeException.ThrowIfGreaterThan(length, Length); -#else - if (length < 0) throw new ArgumentOutOfRangeException(nameof(length), "Length must be greater than or equal to 0."); - if (length > Length) throw new ArgumentOutOfRangeException(nameof(length), "Length must be less than or equal to the unconsumed length of the buffer."); -#endif - - if (length == Length) - { - return; - } - - var remaining = _readIndex + length; - var page = _readPage; - while (remaining > page.Length) - { - remaining -= page.Length; - page = page.Next!; - Debug.Assert(page is not null); - } - - page.SetLength(remaining, page.Version); - var next = page.Next; - page.ClearNext(page.Version); - - while (next is not null) + if (minimumPageSize < 0) { - var current = next; - next = current.Next; - current.SetLength(0, current.Version); - current.Unpin(current.Version); + throw new ArgumentOutOfRangeException(nameof(minimumPageSize)); } - _writePage = _tail = page; - _totalLength = _readIndex + length; - } - - /// - /// Resets this instance, returning all memory. - /// - public void Reset() - { - ThrowIfDisposed(); - UnpinAll(); - _hasPinnedPages = false; _totalLength = _readIndex = 0; - _readPage = _writePage = _tail = ArcBufferPagePool.Shared.Rent(); + _readPage = _writePage = _tail = ArcBufferPagePool.Shared.Rent(minimumPageSize); + _hasPinnedPages = false; Debug.Assert(_readPage.ReferenceCount == 0); _readPage.Pin(_readPage.Version); } @@ -263,20 +153,16 @@ public void Reset() /// public void Dispose() { - if (_disposed) return; - UnpinAll(); - _hasPinnedPages = false; _totalLength = _readIndex = 0; _readPage = _writePage = _tail = null!; - _disposed = true; + _hasPinnedPages = false; } /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public Memory GetMemory(int sizeHint = 0) { - ThrowIfDisposed(); ThrowIfPinnedPages(); if (sizeHint >= _writePage.WriteCapacity) @@ -291,7 +177,6 @@ public Memory GetMemory(int sizeHint = 0) [MethodImpl(MethodImplOptions.AggressiveInlining)] public Span GetSpan(int sizeHint = 0) { - ThrowIfDisposed(); ThrowIfPinnedPages(); if (sizeHint >= _writePage.WriteCapacity) @@ -309,10 +194,13 @@ public Span GetSpan(int sizeHint = 0) /// A span of either zero length, if the data is unavailable, or at least the requested length if the data is available. public ReadOnlySpan Peek(scoped in Span destination) { - ThrowIfDisposed(); + if (destination.Length > UnconsumedLength) + { + return []; + } // Single span. - var firstSpan = _readPage.AsSpan(_readIndex, Math.Min(_readPage.Length - _readIndex, Length)); + var firstSpan = _readPage.AsSpan(_readIndex, Math.Min(_readPage.Length - _readIndex, UnconsumedLength)); if (firstSpan.Length >= destination.Length) { return firstSpan; @@ -327,21 +215,19 @@ public ReadOnlySpan Peek(scoped in Span destination) /// This method does not advance the read cursor. public int Peek(Span output) { - ThrowIfDisposed(); - - var remaining = Math.Min(output.Length, Length); var bytesCopied = 0; + var remaining = UnconsumedLength; var current = _readPage; var offset = _readIndex; - while (remaining > 0 && current != null) + while (output.Length > 0 && remaining > 0 && current != null) { - var segment = current.AsSpan(offset, Math.Min(current.Length - offset, remaining)); - var copyLength = Math.Min(segment.Length, output.Length); + var segment = current.AsSpan(offset, current.Length - offset); + var copyLength = Math.Min(Math.Min(segment.Length, output.Length), remaining); bytesCopied += copyLength; var slice = segment[..copyLength]; slice.CopyTo(output); output = output[slice.Length..]; - remaining -= slice.Length; + remaining -= copyLength; current = current.Next; offset = 0; } @@ -350,189 +236,130 @@ public int Peek(Span output) } /// - /// Gets the contiguous unread bytes in the current read page. + /// Writes the provided sequence to this buffer. /// - internal ReadOnlySpan UnreadSpan + /// The data to write. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(ReadOnlySequence input) { - get - { - ThrowIfDisposed(); - if (Length == 0) - { - return default; - } + ThrowIfPinnedPages(); - return _readPage.AsSpan(_readIndex, Math.Min(_readPage.Length - _readIndex, Length)); + foreach (var segment in input) + { + Write(segment.Span); } } /// - /// Peeks at a byte at the specified offset into the unread data. + /// Writes the provided value to this buffer. /// - internal bool TryPeek(long offset, out byte value) + /// The data to write. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Write(ReadOnlySpan value) { - ThrowIfDisposed(); - - if (offset < 0) - { - throw new ArgumentOutOfRangeException(nameof(offset)); - } + ThrowIfPinnedPages(); - var length = Length; - if (offset >= length) - { - value = default; - return false; - } + var destination = GetSpan(); - // Owned buffers end at the write page's written length; pinned slices can end partway through a page. - if (!_hasPinnedPages) + // Fast path, try copying to the available memory directly + if (value.Length <= destination.Length) { - var writePageOffset = offset - (length - _writePage.Length); - if (writePageOffset >= 0) - { - value = _writePage.AsSpan((int)writePageOffset, 1)[0]; - return true; - } + value.CopyTo(destination); + AdvanceWriter(value.Length); } - - Debug.Assert(offset <= int.MaxValue); - var remainingOffset = (int)offset; - var current = _readPage; - var pageOffset = _readIndex; - while (current is not null) + else { - var available = current.Length - pageOffset; - if (remainingOffset < available) - { - value = current.AsSpan(pageOffset + remainingOffset, 1)[0]; - return true; - } - - remainingOffset -= available; - current = current.Next; - pageOffset = 0; + WriteMultiSegment(value, destination); } - - value = default; - return false; } - /// - /// Returns the offset of the first occurrence of in the unread data, or -1 if it is not found. - /// - internal int IndexOf(byte value) + private void WriteMultiSegment(in ReadOnlySpan source, Span destination) { - ThrowIfDisposed(); - - var result = 0; - var remaining = Length; - var current = _readPage; - var pageOffset = _readIndex; - while (remaining > 0 && current is not null) + var input = source; + while (true) { - var span = current.AsSpan(pageOffset, Math.Min(current.Length - pageOffset, remaining)); - var index = span.IndexOf(value); - if (index >= 0) + var writeSize = Math.Min(destination.Length, input.Length); + input[..writeSize].CopyTo(destination); + AdvanceWriter(writeSize); + input = input[writeSize..]; + if (input.Length > 0) { - return result + index; + destination = GetSpan(); + continue; } - result += span.Length; - remaining -= span.Length; - current = current.Next; - pageOffset = 0; + return; } - - return -1; } /// - /// Returns the offset of the first occurrence of any of in the unread data, or -1 if none are found. + /// Overwrites bytes which have already been written without changing the reader or writer positions. /// - internal int IndexOfAny(ReadOnlySpan values) + public void WriteAt(int offset, ReadOnlySpan value) { - ThrowIfDisposed(); + ThrowIfPinnedPages(); + if (offset < 0 || offset > Length) throw new ArgumentOutOfRangeException(nameof(offset)); + if (value.Length > Length - offset) throw new ArgumentOutOfRangeException(nameof(value)); + if (value.IsEmpty) return; - if (values.IsEmpty) + var page = _readPage; + var pageOffset = _readIndex + offset; + while (pageOffset >= page.Length) { - return -1; + pageOffset -= page.Length; + page = page.Next!; } - var result = 0; - var remaining = Length; - var current = _readPage; - var pageOffset = _readIndex; - while (remaining > 0 && current is not null) + while (!value.IsEmpty) { - var span = current.AsSpan(pageOffset, Math.Min(current.Length - pageOffset, remaining)); - for (var i = 0; i < span.Length; i++) - { - if (values.IndexOf(span[i]) >= 0) - { - return result + i; - } - } - - result += span.Length; - remaining -= span.Length; - current = current.Next; + var count = Math.Min(value.Length, page.Length - pageOffset); + value[..count].CopyTo(page.AsSpan(pageOffset, count)); + value = value[count..]; + page = page.Next!; pageOffset = 0; } - - return -1; } /// - /// Writes the provided sequence to this buffer. + /// Truncates this buffer to the specified unconsumed length. /// - /// The data to write. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Write(ReadOnlySequence input) + public void Truncate(int length) { - ThrowIfDisposed(); + ThrowIfPinnedPages(); + if (length < 0 || length > Length) throw new ArgumentOutOfRangeException(nameof(length)); - foreach (var segment in input) + if (length == Length) { - Write(segment.Span); + return; } - } - - /// - /// Writes the provided value to this buffer. - /// - /// The data to write. - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public void Write(ReadOnlySpan value) - { - ThrowIfDisposed(); - var destination = GetSpan(); - - // Fast path, try copying to the available memory directly - if (value.Length <= destination.Length) + var remaining = _readIndex + length; + var page = _readPage; + while (remaining > page.Length) { - value.CopyTo(destination); - AdvanceWriter(value.Length); + remaining -= page.Length; + page = page.Next!; } - else + + page.SetLength(remaining, page.Version); + var discarded = page.Next; + page.ClearNext(page.Version); + while (discarded is not null) { - WriteMultiSegment(value, destination); + var next = discarded.Next; + discarded.Unpin(discarded.Version); + discarded = next; } + + _writePage = _tail = page; + _totalLength = _readIndex + length; } /// - /// Appends the pages referenced by directly to this writer by pinning them. + /// Appends the pages referenced by by pinning them. /// - /// The buffer to append. - /// - /// The writer must be empty and must not be written to after calling this method. The caller retains ownership - /// of and must dispose it independently. - /// public void AppendPinned(ArcBuffer input) { - ThrowIfDisposed(); - if (input.Length == 0) { return; @@ -552,8 +379,6 @@ public void AppendPinned(ArcBuffer input) tail = segment.Page; } - Debug.Assert(tail is not null); - _readPage.Unpin(_readPage.Version); _readPage = input.First; _writePage = _tail = tail!; @@ -562,24 +387,67 @@ public void AppendPinned(ArcBuffer input) _hasPinnedPages = true; } - private void WriteMultiSegment(in ReadOnlySpan source, Span destination) + private void ThrowIfPinnedPages() { - var input = source; - while (true) + if (_hasPinnedPages) { - var writeSize = Math.Min(destination.Length, input.Length); - input[..writeSize].CopyTo(destination); - AdvanceWriter(writeSize); - input = input[writeSize..]; - if (input.Length > 0) - { - destination = GetSpan(); + throw new InvalidOperationException("A writer containing pinned pages cannot be mutated."); + } + } - continue; + internal bool TryPeek(long offset, out byte value) + { + if (_readPage is null) + { + throw new ObjectDisposedException(nameof(ArcBufferWriter)); + } + + if (offset < 0) + { + throw new ArgumentOutOfRangeException(nameof(offset)); + } + + var length = Length; + if (offset >= length) + { + value = default; + return false; + } + + // Owned buffers end at the write page's written length; pinned slices can end partway through a page. + if (!_hasPinnedPages) + { + var writePageOffset = offset - (length - _writePage.Length); + if (writePageOffset >= 0) + { + value = _writePage.AsSpan((int)writePageOffset, 1)[0]; + return true; } + } - return; + var page = _readPage; + var pageOffset = _readIndex + checked((int)offset); + while (pageOffset >= page.Length) + { + pageOffset -= page.Length; + page = page.Next!; + } + + value = page.Array[pageOffset]; + return true; + } + + internal long IndexOf(byte value) + { + for (long i = 0; i < Length; i++) + { + if (TryPeek(i, out var candidate) && candidate == value) + { + return i; + } } + + return -1; } /// @@ -597,25 +465,21 @@ private void UnpinAll() } /// - /// Returns a pinned slice of the provided length without marking the data referred to it as consumed. + /// Returns a slice of the provided length without marking the data referred to it as consumed. /// /// The number of bytes to consume. - /// - /// A pinned slice of unconsumed data. The caller owns the returned buffer and must dispose it. - /// + /// A slice of unconsumed data. public ArcBuffer PeekSlice(int count) { - ThrowIfDisposed(); - #if NET6_0_OR_GREATER ArgumentOutOfRangeException.ThrowIfLessThan(count, 0); - ArgumentOutOfRangeException.ThrowIfGreaterThan(count, Length); + ArgumentOutOfRangeException.ThrowIfGreaterThan(count, UnconsumedLength); #else if (count < 0) throw new ArgumentOutOfRangeException(nameof(count), "Length must be greater than or equal to 0."); - if (count > Length) throw new ArgumentOutOfRangeException(nameof(count), "Length must be less than or equal to the unconsumed length of the buffer."); + if (count > UnconsumedLength) throw new ArgumentOutOfRangeException(nameof(count), "Length must be less than or equal to the unconsumed length of the buffer."); #endif Debug.Assert(count >= 0); - Debug.Assert(count <= Length); + Debug.Assert(count <= UnconsumedLength); var result = new ArcBuffer(_readPage, token: _readPage.Version, offset: _readIndex, count); result.Pin(); @@ -626,13 +490,9 @@ public ArcBuffer PeekSlice(int count) /// Consumes a slice of the provided length. /// /// The number of bytes to consume. - /// - /// A pinned buffer representing the consumed data. The caller owns the returned buffer and must dispose it. - /// + /// A buffer representing the consumed data. public ArcBuffer ConsumeSlice(int count) { - ThrowIfDisposed(); - var result = PeekSlice(count); // Advance the cursor so that subsequent slice calls will return the next slice. @@ -647,14 +507,11 @@ public ArcBuffer ConsumeSlice(int count) /// The number of bytes to advance the reader. public void AdvanceReader(int count) { - ThrowIfDisposed(); - #if NET6_0_OR_GREATER - ArgumentOutOfRangeException.ThrowIfLessThan(count, 0); - ArgumentOutOfRangeException.ThrowIfGreaterThan(count, Length); + ArgumentOutOfRangeException.ThrowIfNegative(count); + ArgumentOutOfRangeException.ThrowIfGreaterThan(count, UnconsumedLength); #else - if (count < 0) throw new ArgumentOutOfRangeException(nameof(count), "Count must be greater than or equal to 0."); - if (count > Length) throw new ArgumentOutOfRangeException(nameof(count), "Count must be less than or equal to the unconsumed length of the buffer."); + if (count < 0 || count > UnconsumedLength) throw new ArgumentOutOfRangeException(nameof(count)); #endif _readIndex += count; @@ -687,7 +544,6 @@ public void AdvanceReader(int count) private ArcBufferPage AllocatePage(int sizeHint) { - ThrowIfPinnedPages(); Debug.Assert(_tail.Next is null); var newBuffer = ArcBufferPagePool.Shared.Rent(sizeHint); @@ -708,26 +564,12 @@ private ArcBufferPage AdvanceWritePage(int sizeHint) _writePage = next; return next; } - - private void ThrowIfDisposed() - { - if (_disposed) - throw new ObjectDisposedException(nameof(ArcBufferWriter)); - } - - private void ThrowIfPinnedPages() - { - if (_hasPinnedPages) - { - throw new InvalidOperationException("This writer references pages pinned from another buffer and cannot be written to."); - } - } } internal sealed class ArcBufferPagePool { public static ArcBufferPagePool Shared { get; } = new(); - public const int MinimumPageSize = 1024; + public const int MinimumPageSize = 16 * 1024; private readonly ConcurrentQueue _pages = new(); private readonly ConcurrentQueue _largePages = new(); @@ -973,19 +815,10 @@ public Memory AsMemory(int offset, int length) /// The number of bytes to increase the length of this page by. public void Advance(int bytes) { - Debug.Assert(bytes >= 0, "Advance called with negative bytes"); Length += bytes; Debug.Assert(Length <= Array.Length); } - internal void SetLength(int length, int token) - { - CheckValidity(token); - Debug.Assert(length >= 0); - Debug.Assert(length <= Array.Length); - Length = length; - } - /// /// Sets the next page in the sequence. /// @@ -995,11 +828,16 @@ public void SetNext(ArcBufferPage next, int token) { Debug.Assert(Next is null); CheckValidity(token); - Debug.Assert(next is not null, "SetNext called with null next page"); - Debug.Assert(next != this, "SetNext called with self as next page"); Next = next; } + internal void SetLength(int length, int token) + { + CheckValidity(token); + if (length < 0 || length > Array.Length) throw new ArgumentOutOfRangeException(nameof(length)); + Length = length; + } + internal void ClearNext(int token) { CheckValidity(token); @@ -1069,26 +907,16 @@ public void CheckValidity(int token) /// /// Provides reader access to an . /// -public readonly struct ArcBufferReader +/// The writer. +public readonly struct ArcBufferReader(ArcBufferWriter writer) { - private readonly ArcBufferWriter _writer; - - /// - /// Initializes a new instance of the struct. - /// - /// The writer. - internal ArcBufferReader(ArcBufferWriter writer) - { - _writer = writer; - } - /// /// Gets the number of unconsumed bytes. /// public int Length { [MethodImpl(MethodImplOptions.AggressiveInlining)] - get => _writer.Length; + get => writer.UnconsumedLength; } /// @@ -1097,66 +925,53 @@ public int Length /// The destination, which may be used to hold the requested data if the data needs to be copied. /// A span of either zero length, if the data is unavailable, or the requested length if the data is available. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public ReadOnlySpan Peek(scoped in Span destination) => _writer.Peek(in destination); + public ReadOnlySpan Peek(scoped in Span destination) => writer.Peek(in destination); /// /// Peeks at a byte at the specified offset into the unread data. /// - /// The offset into the unread data. - /// The byte at the specified offset. - [MethodImpl(MethodImplOptions.AggressiveInlining)] public byte Peek(long offset) { - if (!_writer.TryPeek(offset, out var value)) + if (!writer.TryPeek(offset, out var value)) { - throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be within the unread data."); + throw new ArgumentOutOfRangeException(nameof(offset)); } return value; } /// - /// Returns a pinned slice of the provided length without marking the data referred to it as consumed. + /// Returns a slice of the provided length without marking the data referred to it as consumed. /// /// The number of bytes to consume. - /// - /// A pinned slice of unconsumed data. The caller owns the returned buffer and must dispose it. - /// + /// A slice of unconsumed data. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public ArcBuffer PeekSlice(int count) => _writer.PeekSlice(count); + public ArcBuffer PeekSlice(int count) => writer.PeekSlice(count); /// /// Consumes a slice of the provided length. /// /// The number of bytes to consume. - /// - /// A pinned buffer representing the consumed data. The caller owns the returned buffer and must dispose it. - /// + /// A buffer representing the consumed data. [MethodImpl(MethodImplOptions.AggressiveInlining)] - public ArcBuffer ConsumeSlice(int count) => _writer.ConsumeSlice(count); + public ArcBuffer ConsumeSlice(int count) => writer.ConsumeSlice(count); /// - /// Reads bytes until is found. + /// Reads through the next occurrence of . /// - /// - /// The pinned bytes before the delimiter, if it was found. The caller owns the returned buffer and must dispose it. - /// - /// The delimiter to search for. - /// Whether to advance past the delimiter when it is found. - /// if the delimiter was found; otherwise, . public bool TryReadTo(out ArcBuffer slice, byte delimiter, bool advancePastDelimiter = true) { - var index = _writer.IndexOf(delimiter); + var index = writer.IndexOf(delimiter); if (index < 0) { slice = default; return false; } - slice = _writer.ConsumeSlice(index); + slice = writer.ConsumeSlice(checked((int)index)); if (advancePastDelimiter) { - _writer.AdvanceReader(1); + writer.AdvanceReader(1); } return true; @@ -1165,19 +980,16 @@ public bool TryReadTo(out ArcBuffer slice, byte delimiter, bool advancePastDelim /// /// Checks whether the next bytes match . /// - /// The bytes to compare to the next bytes. - /// Whether to advance past the bytes if they match. - /// if the next bytes match; otherwise, . public bool IsNext(ReadOnlySpan next, bool advancePast = false) { - if (next.Length > _writer.Length) + if (next.Length > writer.Length) { return false; } for (var i = 0; i < next.Length; i++) { - if (!_writer.TryPeek(i, out var value) || value != next[i]) + if (!writer.TryPeek(i, out var value) || value != next[i]) { return false; } @@ -1185,7 +997,7 @@ public bool IsNext(ReadOnlySpan next, bool advancePast = false) if (advancePast) { - _writer.AdvanceReader(next.Length); + writer.AdvanceReader(next.Length); } return true; @@ -1197,22 +1009,18 @@ public bool IsNext(ReadOnlySpan next, bool advancePast = false) /// public void Consume(Span output) { - var count = _writer.Peek(output); + var count = writer.Peek(output); if (count != output.Length) { throw new InvalidOperationException("Attempted to consume more data than is available."); } - _writer.AdvanceReader(count); + writer.AdvanceReader(count); } - /// - /// Advances the reader by the specified number of bytes. - /// - /// The number of bytes to advance. public void Skip(int count) { - _writer.AdvanceReader(count); + writer.AdvanceReader(count); } } @@ -1252,11 +1060,6 @@ public struct ArcBuffer(ArcBufferPage first, int token, int offset, int length) public readonly int CopyTo(Span output) { CheckValidity(); - if (output.Length < Length) - { - throw new ArgumentException("Destination span is not large enough to hold the buffer contents.", nameof(output)); - } - var copied = 0; foreach (var span in this) { @@ -1339,18 +1142,6 @@ private static void WriteMultiSegment(ref TBufferWriter writer, i /// public readonly ReadOnlySequence AsReadOnlySequence() { - if (Length == 0) - { - return ReadOnlySequence.Empty; - } - - CheckValidity(); - - if (Length <= First.Length - Offset) - { - return new ReadOnlySequence(First.AsMemory(Offset, Length)); - } - var runningIndex = 0L; ReadOnlySequenceSegment? first = null; ReadOnlySequenceSegment? previous = null; @@ -1512,16 +1303,7 @@ public void Unpin() } /// - public void Dispose() - { - if (_firstPageToken == -1) - { - // Already disposed. - return; - } - - Unpin(); - } + public void Dispose() => Unpin(); /// /// Returns an enumerator which can be used to enumerate the span segments referenced by this instance. @@ -1637,11 +1419,11 @@ internal struct PageSegmentEnumerator(ArcBuffer slice) : IEnumerable if the enumerator was successfully advanced to the next element; if the enumerator has passed the end of the collection. public bool MoveNext() { - Debug.Assert(_position <= Length, "Enumerator position exceeds slice length"); + Debug.Assert(_position <= Length); if (_page is null || _position == Length) { Current = default; - Debug.Assert(_position == Length, "Enumerator ended before reaching full length"); + Debug.Assert(_position == Length); return false; } @@ -1651,7 +1433,6 @@ public bool MoveNext() Slice.CheckValidity(); var offset = Offset; var length = Math.Min(Length, _page.Length - offset); - Debug.Assert(length >= 0, "Calculated negative length for first segment"); _position += length; Current = new PageSegment(_page, offset, length); _page = _page.Next; @@ -1660,7 +1441,6 @@ public bool MoveNext() { var length = Math.Min(Length - _position, _page.Length); - Debug.Assert(length >= 0, "Calculated negative length for subsequent segment"); _position += length; Current = new PageSegment(_page, 0, length); _page = _page.Next; diff --git a/src/Orleans.Serialization/Buffers/Reader.cs b/src/Orleans.Serialization/Buffers/Reader.cs index 0449b2c1396..7d3c1725cc5 100644 --- a/src/Orleans.Serialization/Buffers/Reader.cs +++ b/src/Orleans.Serialization/Buffers/Reader.cs @@ -576,13 +576,13 @@ public void Skip(long count) /// /// Creates a new reader beginning at the specified position. - /// + /// /// /// The position in the input stream to fork from. - /// + /// /// /// The forked reader instance. - /// + /// public void ForkFrom(long position, out Reader forked) { if (IsReadOnlySequenceInput) diff --git a/src/Orleans.TestingHost/InMemoryTransport/InMemoryMessageTransport.cs b/src/Orleans.TestingHost/InMemoryTransport/InMemoryMessageTransport.cs new file mode 100644 index 00000000000..fc5dbd3380e --- /dev/null +++ b/src/Orleans.TestingHost/InMemoryTransport/InMemoryMessageTransport.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO.Pipelines; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Orleans.Connections.Transport; +using Orleans.Runtime; +using Orleans.Runtime.Internal; +using Orleans.Serialization.Buffers; + +namespace Orleans.TestingHost.InMemoryTransport; + +internal class InMemoryMessageTransport : MessageTransportBase +{ + private const int MinReadSize = 256; + private readonly Queue _readRequests = new(); + private readonly SingleWaiterAutoResetEvent _readSignal = new() { RunContinuationsAsynchronously = false }; + private readonly SingleWaiterAutoResetEvent _writeSignal = new() { RunContinuationsAsynchronously = true }; + private readonly Action _fireReadSignal; + private readonly Action _fireWriteSignal; + private readonly PipeReader _pipeReader; + private readonly PipeWriter _pipeWriter; + private readonly ILogger _logger; + private readonly CancellationTokenSource _connectionClosingCts = new(); + private readonly CancellationTokenSource _processingCompleted = new(); + private readonly object _shutdownLock = new(); + private readonly object _writesLock = new(); + private readonly object _readsLock = new(); + private Queue _writeRequests = new(); + private bool _readsCompleted; + private bool _writesCompleted; + private Task? _processingTask; + private volatile Exception? _shutdownReason; + private int _disposeStarted; + + public InMemoryMessageTransport(IDuplexPipe pipe, ILogger logger) + { + _pipeReader = pipe.Input; + _pipeWriter = pipe.Output; + _logger = logger; + + _fireReadSignal = _readSignal.Signal; + _fireWriteSignal = _writeSignal.Signal; + } + + public override CancellationToken Closed => _processingCompleted.Token; + + public void Start() + { + using var _ = new ExecutionContextSuppressor(); + _processingTask = ProcessConnectionAsync(); + } + + private async Task ProcessConnectionAsync() + { + // Return immediately to the synchronous caller. + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + + try + { + // Spawn send and receive logic + (var receiveTask, var sendTask) = StartProcessing(); + + (Task ReceiveTask, Task SendTask) StartProcessing() + { + using (new ExecutionContextSuppressor()) + { + var receiveTask = ProcessReads(); + var sendTask = ProcessWrites(); + return (receiveTask, sendTask); + } + } + + // Wait for both to complete + try + { + await receiveTask; + } + catch (Exception ex) + { + _logger.LogError(0, ex, $"Unexpected exception in {nameof(InMemoryMessageTransport)}.{nameof(ProcessReads)}."); + } + + try + { + await sendTask; + } + catch (Exception ex) + { + _logger.LogError(0, ex, $"Unexpected exception in {nameof(InMemoryMessageTransport)}.{nameof(ProcessWrites)}."); + } + } + catch (Exception ex) + { + _shutdownReason ??= ex; + _logger.LogError(0, ex, $"Unexpected exception in {nameof(InMemoryMessageTransport)}.{nameof(ProcessConnectionAsync)}."); + } + finally + { + _processingCompleted.Cancel(); + await CloseCoreAsync(); + } + } + + public override bool EnqueueRead(ReadRequest request) + { + if (_connectionClosingCts.IsCancellationRequested) + { + return false; + } + + lock (_readsLock) + { + if (_readsCompleted) + { + return false; + } + + _readRequests.Enqueue(request); + } + + _readSignal.Signal(); + return true; + } + + public override bool EnqueueWrite(WriteRequest request) + { + if (_connectionClosingCts.IsCancellationRequested) + { + return false; + } + + lock (_writesLock) + { + if (_writesCompleted) + { + return false; + } + + _writeRequests.Enqueue(request); + } + + _writeSignal.Signal(); + return true; + } + + public override async ValueTask CloseAsync(Exception? closeReason = null, CancellationToken cancellationToken = default) + { + if (_processingCompleted.IsCancellationRequested) + { + return; + } + + _shutdownReason ??= closeReason; + await CloseCoreAsync(); + + if (_processingTask is null) + { + return; + } + + // Wait for completion or cancellation + try + { + await _processingTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // If cancellation was requested, force close + _connectionClosingCts.Cancel(); + } + } + + private async Task CloseCoreAsync() + { + await _pipeReader.CompleteAsync(); + await _pipeWriter.CompleteAsync(); + + _connectionClosingCts.Cancel(); + _readSignal.Signal(); + _writeSignal.Signal(); + } + + public override async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + { + return; + } + + try + { + await CloseAsync(null).ConfigureAwait(false); + if (_processingTask is { } processingTask) + { + await processingTask.ConfigureAwait(false); + } + else + { + await _processingCompleted.CancelAsync().ConfigureAwait(false); + } + + await base.DisposeAsync().ConfigureAwait(false); + } + finally + { + _connectionClosingCts.Dispose(); + _processingCompleted.Dispose(); + GC.SuppressFinalize(this); + } + } + + private async Task ProcessReads() + { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + bool isGracefulTermination = false; + Exception? error = null; + ReadRequest? request = null; + var pipeCompleted = false; + using ArcBufferWriter bufferWriter = new(); + var reader = new ArcBufferReader(bufferWriter); + try + { + // Loop until termination. + while (!_connectionClosingCts.IsCancellationRequested) + { + // Handle each request. + while (TryDequeue(out request)) + { + // Process the request to completion. + while (true) + { + if (request.OnRead(reader)) + { + break; + } + + if (pipeCompleted) + { + goto gracefulTermination; + } + + var readResult = await _pipeReader.ReadAsync(_connectionClosingCts.Token); + bufferWriter.Write(readResult.Buffer); + _pipeReader.AdvanceTo(readResult.Buffer.End); + pipeCompleted = readResult.IsCompleted; + if (readResult.IsCanceled) + { + goto gracefulTermination; + } + } + + request = null; + } + + await _readSignal.WaitAsync().ConfigureAwait(false); + } + +gracefulTermination: + isGracefulTermination = true; + } + catch (Exception exception) + { + error = exception; + isGracefulTermination = false; + } + finally + { + if (isGracefulTermination) + { + request?.OnCanceled(); + } + else + { + Debug.Assert(error is not null); + request?.OnError(error); + } + + _shutdownReason ??= error; + _connectionClosingCts.Cancel(); + _writeSignal.Signal(); + + lock (_readsLock) + { + _readsCompleted = true; + } + + while (TryDequeue(out request)) + { + if (isGracefulTermination) + { + request.OnCanceled(); + } + else + { + Debug.Assert(error is not null); + request.OnError(error); + } + } + } + + bool TryDequeue([NotNullWhen(true)] out ReadRequest? request) + { + lock (_readsLock) + { + return _readRequests.TryDequeue(out request); + } + } + } + + private async Task ProcessWrites() + { + await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); + + const int SoftBatchMax = 32; + Exception? error = null; + Queue requests = new(); + List processingRequests = new(capacity: SoftBatchMax); + + try + { + // Loop until termination. + while (!_connectionClosingCts.IsCancellationRequested) + { + if (requests.Count == 0) + { + // Check for pending messages before waiting. + RefreshRequestQueue(ref requests); + + if (requests.Count == 0) + { + await _writeSignal.WaitAsync().ConfigureAwait(false); + continue; + } + } + + processingRequests.Clear(); + + while (error is null && processingRequests.Count < SoftBatchMax && requests.TryDequeue(out var request)) + { + processingRequests.Add(request); + using var slice = request.Buffers.ConsumeSlice(request.Buffers.Length); + foreach (var buffer in slice.MemorySegments) + { + var flushResult = await _pipeWriter.WriteAsync(buffer, _connectionClosingCts.Token); + if (flushResult.IsCanceled) + { + error = new OperationCanceledException(); + break; + } + + if (flushResult.IsCompleted) + { + error = new ConnectionClosedException("The in-memory transport peer closed before all queued data was written."); + break; + } + } + } + + if (error is not null) + { + // Bubble the error up + break; + } + + // Signal that the requests are completed + foreach (var request in processingRequests) + { + request.SetResult(); + } + + processingRequests.Clear(); + } + } + catch (Exception ex) + { + // This is unexpected. + error = ex; + } + finally + { + _shutdownReason ??= error; + _connectionClosingCts.Cancel(); + + var requestError = _shutdownReason ?? new ConnectionClosedException(); + foreach (var request in processingRequests) + { + request.SetException(requestError); + } + + _readSignal.Signal(); + + lock (_writesLock) + { + _writesCompleted = true; + } + + // Drain requests. + while (requests.TryDequeue(out var request) || _writeRequests.TryDequeue(out request)) + { + request.SetException(requestError); + } + } + + void RefreshRequestQueue(ref Queue queue) + { + lock (_writesLock) + { + queue = Interlocked.Exchange(ref _writeRequests, queue); + } + } + } + + public override string ToString() => $"InMemoryTransport()"; +} diff --git a/src/Orleans.TestingHost/InMemoryTransport/InMemoryTransportConnection.cs b/src/Orleans.TestingHost/InMemoryTransport/InMemoryTransportConnection.cs deleted file mode 100644 index d6e2f220678..00000000000 --- a/src/Orleans.TestingHost/InMemoryTransport/InMemoryTransportConnection.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Buffers; -using System.IO.Pipelines; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.Logging; -using Orleans.Networking.Shared; - -namespace Orleans.TestingHost.InMemoryTransport; - -internal partial class InMemoryTransportConnection : TransportConnection -{ - private readonly CancellationTokenSource _connectionClosedTokenSource = new(); - private readonly ILogger _logger; - private bool _isClosed; - private readonly TaskCompletionSource _waitForCloseTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - - private InMemoryTransportConnection(MemoryPool memoryPool, ILogger logger, DuplexPipe.DuplexPipePair pair, EndPoint localEndPoint, EndPoint remoteEndPoint) - { - MemoryPool = memoryPool; - _logger = logger; - - LocalEndPoint = localEndPoint; - RemoteEndPoint = remoteEndPoint; - - Application = pair.Application; - Transport = pair.Transport; - - ConnectionClosed = _connectionClosedTokenSource.Token; - } - - public static InMemoryTransportConnection Create(MemoryPool memoryPool, ILogger logger, EndPoint localEndPoint, EndPoint remoteEndPoint) - { - var pair = DuplexPipe.CreateConnectionPair( - new PipeOptions(memoryPool, readerScheduler: PipeScheduler.Inline, useSynchronizationContext: false), - new PipeOptions(memoryPool, writerScheduler: PipeScheduler.Inline, useSynchronizationContext: false)); - return new InMemoryTransportConnection(memoryPool, logger, pair, localEndPoint, remoteEndPoint); - } - - public static InMemoryTransportConnection Create(MemoryPool memoryPool, ILogger logger, InMemoryTransportConnection other, EndPoint localEndPoint) - { - // Swap the application & tranport pipes since we're going in the other direction. - var pair = new DuplexPipe.DuplexPipePair(transport: other.Application, application: other.Transport); - var remoteEndPoint = other.LocalEndPoint; - return new InMemoryTransportConnection(memoryPool, logger, pair, localEndPoint, remoteEndPoint!); // In-memory connections are always created with a local endpoint. - } - - public override MemoryPool MemoryPool { get; } - - public Task WaitForCloseTask => _waitForCloseTcs.Task; - - public override void Abort(ConnectionAbortedException? abortReason) - { - LogDebugConnectionClosing(_logger, ConnectionId, abortReason?.Message); - - Transport.Input.CancelPendingRead(); - Transport.Output.CancelPendingFlush(); - - OnClosed(); - } - - public void OnClosed() - { - if (_isClosed) - { - return; - } - - _isClosed = true; - - ThreadPool.UnsafeQueueUserWorkItem(state => - { - state._connectionClosedTokenSource.Cancel(); - - state._waitForCloseTcs.TrySetResult(true); - }, - this, - preferLocal: false); - } - - public override async ValueTask DisposeAsync() - { - Abort(null); - await _waitForCloseTcs.Task; - - _connectionClosedTokenSource.Dispose(); - } - - public override string ToString() => $"InMem({LocalEndPoint}<->{RemoteEndPoint})"; - - [LoggerMessage( - Level = LogLevel.Debug, - Message = "Connection id \"{ConnectionId}\" closing because: \"{Message}\"" - )] - private static partial void LogDebugConnectionClosing(ILogger logger, string connectionId, string? message); -} diff --git a/src/Orleans.TestingHost/InMemoryTransport/InMemoryTransportHostingExtensions.cs b/src/Orleans.TestingHost/InMemoryTransport/InMemoryTransportHostingExtensions.cs new file mode 100644 index 00000000000..e9cec60d8c0 --- /dev/null +++ b/src/Orleans.TestingHost/InMemoryTransport/InMemoryTransportHostingExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.Connections.Transport; +using Orleans.Hosting; +using Orleans.Runtime.Messaging; + +namespace Orleans.TestingHost.InMemoryTransport; + +internal static class InMemoryTransportHostingExtensions +{ + public static IClientBuilder UseInMemoryTransport(this IClientBuilder clientBuilder, InMemoryTransportConnectionHub hub) + { + clientBuilder.Services.RemoveAll(); + clientBuilder.Services.AddSingleton(sp => new InMemoryTransportConnector(hub, sp.GetRequiredService())); + return clientBuilder; + } + + public static ISiloBuilder UseInMemoryTransport(this ISiloBuilder siloBuilder, InMemoryTransportConnectionHub hub) + { + siloBuilder.Services.RemoveAll(); + siloBuilder.Services.RemoveAll(); + siloBuilder.Services.AddSingleton(sp => new InMemoryTransportConnector(hub, sp.GetRequiredService())); + siloBuilder.Services.AddSingleton(sp => new InMemoryTransportListener( + GatewayConnectionListener.DefaultListenerName, + sp.GetRequiredService>().Value.GetListeningProxyEndpoint()?.ToString(), + hub)); + siloBuilder.Services.AddSingleton(sp => new InMemoryTransportListener( + SiloConnectionListener.DefaultListenerName, + sp.GetRequiredService>().Value.GetListeningSiloEndpoint()!.ToString(), + hub)); + return siloBuilder; + } +} diff --git a/src/Orleans.TestingHost/InMemoryTransport/InMemoryTransportListenerFactory.cs b/src/Orleans.TestingHost/InMemoryTransport/InMemoryTransportListenerFactory.cs index 817b1fa4256..9a7a8c6b240 100644 --- a/src/Orleans.TestingHost/InMemoryTransport/InMemoryTransportListenerFactory.cs +++ b/src/Orleans.TestingHost/InMemoryTransport/InMemoryTransportListenerFactory.cs @@ -1,86 +1,48 @@ -using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO.Pipelines; using System.Net; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Orleans.Hosting; -using Orleans.Networking.Shared; +using Orleans.Connections.Transport; using Orleans.Runtime.Messaging; namespace Orleans.TestingHost.InMemoryTransport; -internal static class InMemoryTransportExtensions +internal class InMemoryTransportListener : MessageTransportListener { - public static ISiloBuilder UseInMemoryConnectionTransport(this ISiloBuilder siloBuilder, InMemoryTransportConnectionHub hub) - { - siloBuilder.ConfigureServices(services => - { - services.AddKeyedSingleton(SiloConnectionFactory.ServicesKey, CreateInMemoryConnectionFactory(hub)); - services.AddKeyedSingleton(SiloConnectionListener.ServicesKey, CreateInMemoryConnectionListenerFactory(hub)); - services.AddKeyedSingleton(GatewayConnectionListener.ServicesKey, CreateInMemoryConnectionListenerFactory(hub)); - }); - - return siloBuilder; - } - - public static IClientBuilder UseInMemoryConnectionTransport(this IClientBuilder clientBuilder, InMemoryTransportConnectionHub hub) - { - clientBuilder.ConfigureServices(services => - { - services.AddKeyedSingleton(ClientOutboundConnectionFactory.ServicesKey, CreateInMemoryConnectionFactory(hub)); - }); - - return clientBuilder; - } - - private static Func CreateInMemoryConnectionFactory(InMemoryTransportConnectionHub hub) - { - return (IServiceProvider sp, object? key) => - { - var loggerFactory = sp.GetRequiredService(); - var sharedMemoryPool = sp.GetRequiredService(); - return new InMemoryTransportConnectionFactory(hub, loggerFactory, sharedMemoryPool); - }; - } - - private static Func CreateInMemoryConnectionListenerFactory(InMemoryTransportConnectionHub hub) - { - return (IServiceProvider sp, object? key) => - { - var loggerFactory = sp.GetRequiredService(); - var sharedMemoryPool = sp.GetRequiredService(); - return new InMemoryTransportListener(hub, loggerFactory, sharedMemoryPool); - }; - } -} - -internal class InMemoryTransportListener : IConnectionListenerFactory, IConnectionListener -{ - private readonly Channel<(InMemoryTransportConnection Connection, TaskCompletionSource ConnectionAcceptedTcs)> _acceptQueue = Channel.CreateUnbounded<(InMemoryTransportConnection, TaskCompletionSource)>(); + private readonly Channel<(InMemoryMessageTransport Connection, TaskCompletionSource ConnectionAcceptedTcs)> _acceptQueue = Channel.CreateUnbounded<(InMemoryMessageTransport, TaskCompletionSource)>(); + private readonly string? _endpointValue; private readonly InMemoryTransportConnectionHub _hub; - private readonly ILoggerFactory _loggerFactory; - private readonly SharedMemoryPool _memoryPool; private readonly CancellationTokenSource _disposedCts = new(); - public InMemoryTransportListener(InMemoryTransportConnectionHub hub, ILoggerFactory loggerFactory, SharedMemoryPool memoryPool) + public InMemoryTransportListener(string endpointName, string? endpointValue, InMemoryTransportConnectionHub hub) { + ListenerName = endpointName; + _endpointValue = endpointValue; _hub = hub; - _loggerFactory = loggerFactory; - _memoryPool = memoryPool; } public CancellationToken OnDisposed => _disposedCts.Token; - public EndPoint EndPoint { get; set; } = null!; + public override bool IsValid => _endpointValue is not null && !OnDisposed.IsCancellationRequested; + public override IFeatureCollection Features { get; } = new FeatureCollection(); + public override string ListenerName { get; } - public async Task ConnectAsync(InMemoryTransportConnection connection) + public async Task AddConnection(InMemoryMessageTransport connection, CancellationToken cancellationToken) { var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = cancellationToken.UnsafeRegister( + static state => + { + var (source, token) = ((TaskCompletionSource, CancellationToken))state!; + source.TrySetCanceled(token); + }, + (completion, cancellationToken)); + if (_acceptQueue.Writer.TryWrite((connection, completion))) { var connected = await completion.Task; @@ -90,45 +52,42 @@ public async Task ConnectAsync(InMemoryTransportConnection connection) } } - throw new ConnectionFailedException($"Unable to connect to {EndPoint} because its listener has terminated."); + throw new ConnectionFailedException($"Unable to connect to endpoint because its listener has terminated."); } - public async ValueTask AcceptAsync(CancellationToken cancellationToken = default) + public override async ValueTask AcceptAsync(CancellationToken cancellationToken = default) { - if (await _acceptQueue.Reader.WaitToReadAsync(cancellationToken)) + while (await _acceptQueue.Reader.WaitToReadAsync(cancellationToken)) { - if (_acceptQueue.Reader.TryRead(out var item)) + while (_acceptQueue.Reader.TryRead(out var item)) { - var remoteConnectionContext = item.Connection; - var localConnectionContext = InMemoryTransportConnection.Create( - _memoryPool.Pool, - _loggerFactory.CreateLogger(), - other: remoteConnectionContext, - localEndPoint: EndPoint); - - // Set the result to true to indicate that the connection was accepted. - item.ConnectionAcceptedTcs.TrySetResult(true); - - return localConnectionContext; + // Acceptance and cancellation race through the same completion source. + if (item.ConnectionAcceptedTcs.TrySetResult(true)) + { + return item.Connection; + } } } return null; } - public ValueTask BindAsync(EndPoint endpoint, CancellationToken cancellationToken = default) + public override ValueTask BindAsync(CancellationToken cancellationToken = default) { - EndPoint = endpoint; - _hub.RegisterConnectionListenerFactory(endpoint, this); - return new ValueTask(this); + if (_endpointValue is not null) + { + _hub.RegisterConnectionListenerFactory(_endpointValue, this); + } + + return default; } - public ValueTask DisposeAsync() + public override ValueTask DisposeAsync() { return UnbindAsync(default); } - public ValueTask UnbindAsync(CancellationToken cancellationToken = default) + public override ValueTask UnbindAsync(CancellationToken cancellationToken = default) { _acceptQueue.Writer.TryComplete(); while (_acceptQueue.Reader.TryRead(out var item)) @@ -144,55 +103,82 @@ public ValueTask UnbindAsync(CancellationToken cancellationToken = default) internal class InMemoryTransportConnectionHub { - private readonly ConcurrentDictionary _listeners = new(); + private readonly ConcurrentDictionary _listeners = new(); public static InMemoryTransportConnectionHub Instance { get; } = new(); - public void RegisterConnectionListenerFactory(EndPoint endPoint, InMemoryTransportListener listener) + public void RegisterConnectionListenerFactory(string endpoint, InMemoryTransportListener listener) { - _listeners[endPoint] = listener; + _listeners[endpoint] = listener; listener.OnDisposed.Register(() => { - ((IDictionary)_listeners).Remove(new KeyValuePair(endPoint, listener)); + ((IDictionary)_listeners).Remove(new KeyValuePair(endpoint, listener)); }); } - public InMemoryTransportListener? GetConnectionListenerFactory(EndPoint endPoint) + public async ValueTask GetConnectionListenerFactoryAsync(string endpoint, CancellationToken cancellationToken) { - _listeners.TryGetValue(endPoint, out var listener); - return listener; + while (true) + { + if (_listeners.TryGetValue(endpoint, out var listener) && listener.IsValid) + { + return listener; + } + + await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationToken).ConfigureAwait(false); + } } } -internal class InMemoryTransportConnectionFactory : IConnectionFactory +internal class InMemoryTransportConnector : MessageTransportConnector { private readonly InMemoryTransportConnectionHub _hub; - private readonly ILoggerFactory _loggerFactory; - private readonly SharedMemoryPool _memoryPool; - private readonly IPEndPoint _localEndpoint; + private readonly ILogger _connectionLogger; + + public override IFeatureCollection Features { get; } = new FeatureCollection(); + public override bool IsValid => true; - public InMemoryTransportConnectionFactory(InMemoryTransportConnectionHub hub, ILoggerFactory loggerFactory, SharedMemoryPool memoryPool) + public InMemoryTransportConnector(InMemoryTransportConnectionHub hub, ILoggerFactory loggerFactory) { _hub = hub; - _loggerFactory = loggerFactory; - _memoryPool = memoryPool; - _localEndpoint = new IPEndPoint(IPAddress.Loopback, Random.Shared.Next(1024, ushort.MaxValue - 1024)); + _connectionLogger = loggerFactory.CreateLogger(); } - public async ValueTask ConnectAsync(EndPoint endpoint, CancellationToken cancellationToken = default) + public override async ValueTask CreateAsync(EndPoint endpoint, CancellationToken cancellationToken = default) { - var listener = _hub.GetConnectionListenerFactory(endpoint); - if (listener is null) + var listener = await _hub.GetConnectionListenerFactoryAsync(endpoint.ToString()!, cancellationToken).ConfigureAwait(false); + + var pipePair = DuplexPipe.CreatePair(); + var local = new InMemoryMessageTransport(pipePair.Left, _connectionLogger); + local.Start(); + var remote = new InMemoryMessageTransport(pipePair.Right, _connectionLogger); + remote.Start(); + try + { + await listener.AddConnection(remote, cancellationToken); + return local; + } + catch { - throw new ConnectionFailedException($"Unable to connect to endpoint {endpoint} because no such endpoint is currently registered."); + await local.DisposeAsync(); + await remote.DisposeAsync(); + throw; } + } + + private class DuplexPipe : IDuplexPipe + { + public required PipeReader Input { get; init; } + public required PipeWriter Output { get; init; } - var connectionContext = InMemoryTransportConnection.Create( - _memoryPool.Pool, - _loggerFactory.CreateLogger(), - _localEndpoint, - endpoint); - await listener.ConnectAsync(connectionContext).WaitAsync(cancellationToken); - return connectionContext; + public static (DuplexPipe Left, DuplexPipe Right) CreatePair() + { + var pipeOptions = new PipeOptions(readerScheduler: PipeScheduler.Inline, writerScheduler: PipeScheduler.Inline, useSynchronizationContext: false); + var one = new Pipe(pipeOptions); + var two = new Pipe(pipeOptions); + var left = new DuplexPipe { Input = one.Reader, Output = two.Writer }; + var right = new DuplexPipe { Input = two.Reader, Output = one.Writer }; + return (left, right); + } } } diff --git a/src/Orleans.TestingHost/InProcTestCluster.cs b/src/Orleans.TestingHost/InProcTestCluster.cs index 85196685e3d..545e5635372 100644 --- a/src/Orleans.TestingHost/InProcTestCluster.cs +++ b/src/Orleans.TestingHost/InProcTestCluster.cs @@ -26,11 +26,12 @@ using Orleans.Runtime.TestHooks; using Orleans.Configuration.Internal; using Orleans.TestingHost.Logging; +using Microsoft.Extensions.Logging; namespace Orleans.TestingHost; /// -/// A host class for local testing with Orleans using in-process silos. +/// A host class for local testing with Orleans using in-process silos. /// public sealed class InProcessTestCluster : IDisposable, IAsyncDisposable { @@ -62,7 +63,7 @@ public ReadOnlyCollection Silos /// /// Options used to configure the test cluster. /// - /// This is the options you configured your test cluster with, or the default one. + /// This is the options you configured your test cluster with, or the default one. /// If the cluster is being configured via ClusterConfiguration, then this object may not reflect the true settings. /// public InProcessTestClusterOptions Options { get; } @@ -200,7 +201,7 @@ public async Task DeactivateAsync(GrainId grainId) { if (TryGetGrainContext(grainId, out var grainContext)) { - grainContext.Deactivate(new DeactivationReason(DeactivationReasonCode.ApplicationRequested, $"{nameof(DeactivateAsync)} was called.")); + grainContext!.Deactivate(new DeactivationReason(DeactivationReasonCode.ApplicationRequested, $"{nameof(DeactivateAsync)} was called.")); await grainContext.Deactivated; } } @@ -379,7 +380,7 @@ public IEnumerable GetActiveSilos() /// Whether recent membership changes we done by graceful Stop. public async Task WaitForLivenessToStabilizeAsync(bool didKill = false) { - var clusterMembershipOptions = Client!.ServiceProvider.GetRequiredService>().Value; // Stabilization requires a deployed client. + var clusterMembershipOptions = Client!.ServiceProvider.GetRequiredService>().Value; TimeSpan stabilizationTime = GetLivenessStabilizationTime(clusterMembershipOptions, didKill); var activeSilos = GetActiveSilos().ToArray(); var testHooks = activeSilos.Select(static silo => (ITestHooks)silo.ServiceProvider.GetRequiredService()).ToArray(); @@ -405,12 +406,11 @@ public async Task WaitForLivenessToStabilizeAsync(bool didKill = false) } /// - /// Wait for active silos to observe cluster manifest updates for all active silos. + /// Waits for active silos to observe cluster manifest updates for all active silos. /// - /// Whether recent membership changes were done by graceful Stop. public async Task WaitForClusterManifestToStabilizeAsync(bool didKill = false) { - var clusterMembershipOptions = Client!.ServiceProvider.GetRequiredService>().Value; // Stabilization requires a deployed client. + var clusterMembershipOptions = Client!.ServiceProvider.GetRequiredService>().Value; var stabilizationTime = GetLivenessStabilizationTime(clusterMembershipOptions, didKill); var activeSilos = GetActiveSilos().ToArray(); var testHooks = activeSilos.Select(static silo => (ITestHooks)silo.ServiceProvider.GetRequiredService()).ToArray(); @@ -871,7 +871,7 @@ public async Task InitializeClientAsync(CancellationToken cancellationToken) clientBuilder.Services.AddSingleton(_membershipTable); } - clientBuilder.UseInMemoryConnectionTransport(_transportHub); + clientBuilder.UseInMemoryTransport(_transportHub); }); TryConfigureFileLogging(Options, hostBuilder.Services, "TestClusterClient"); @@ -1000,7 +1000,7 @@ public async Task CreateSiloAsync( new ConfigureDistributedGrainDirectory().Configure(siloBuilder); } - siloBuilder.UseInMemoryConnectionTransport(_transportHub); + siloBuilder.UseInMemoryTransport(_transportHub); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Orleans.TestingHost/Orleans.TestingHost.csproj b/src/Orleans.TestingHost/Orleans.TestingHost.csproj index 04d537c2109..f2a78140f05 100644 --- a/src/Orleans.TestingHost/Orleans.TestingHost.csproj +++ b/src/Orleans.TestingHost/Orleans.TestingHost.csproj @@ -16,12 +16,15 @@ + + + diff --git a/src/Orleans.TestingHost/TestCluster.cs b/src/Orleans.TestingHost/TestCluster.cs index 3b20f96fbd1..731054ead49 100644 --- a/src/Orleans.TestingHost/TestCluster.cs +++ b/src/Orleans.TestingHost/TestCluster.cs @@ -16,22 +16,26 @@ using Orleans.Configuration; using Microsoft.Extensions.Options; using Microsoft.Extensions.Hosting; -using Orleans.TestingHost.InMemoryTransport; +using Orleans.Runtime.Messaging; +using Orleans.Connections.Transport; using Orleans.TestingHost.UnixSocketTransport; using System.Net; using Orleans.Statistics; -using Orleans.Runtime.TestHooks; +using Orleans.TestingHost.InMemoryTransport; using Orleans.Messaging; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Orleans.Runtime.TestHooks; namespace Orleans.TestingHost { /// - /// A host class for local testing with Orleans using in-process silos. + /// A host class for local testing with Orleans using in-process silos. /// Runs a Primary and optionally secondary silos in separate app domains, and client in the main app domain. /// Additional silos can also be started in-process on demand if required for particular test cases. /// /// - /// Make sure that your test project references your test grains and test grain interfaces + /// Make sure that your test project references your test grains and test grain interfaces /// projects, and has CopyLocal=True set on those references [which should be the default]. /// public class TestCluster : IDisposable, IAsyncDisposable @@ -92,7 +96,7 @@ public ReadOnlyCollection Silos /// /// Options used to configure the test cluster. /// - /// This is the options you configured your test cluster with, or the default one. + /// This is the options you configured your test cluster with, or the default one. /// If the cluster is being configured via ClusterConfiguration, then this object may not reflect the true settings. /// public TestClusterOptions Options => this.options; @@ -131,7 +135,6 @@ public bool ContainsSilo(SiloAddress siloAddress) /// /// GrainFactory to use in the tests /// - /// The cluster has not been deployed or the client has been stopped. public IGrainFactory GrainFactory => this.Client; /// @@ -467,7 +470,7 @@ public async Task WaitForLivenessToStabilizeAsync(bool didKill = false) TimeSpan stabilizationTime = GetLivenessStabilizationTime(clusterMembershipOptions, didKill); var activeSilos = GetActiveSilos().ToArray(); var testHooks = activeSilos.Select(GetTestHooks).ToArray(); - var gatewayManager = this.InternalClient!.ServiceProvider.GetRequiredService(); // Stabilization requires an initialized client. + var gatewayManager = this.InternalClient!.ServiceProvider.GetRequiredService(); var inProcessSilos = activeSilos.OfType().ToArray(); Func>? waitForGrainDirectoryConvergence = inProcessSilos.Length == activeSilos.Length && GrainDirectoryObserver.CanObserve(inProcessSilos) @@ -496,19 +499,18 @@ private ITestHooks GetTestHooks(SiloHandle silo) return inProcessSilo.ServiceProvider.GetRequiredService(); } - return this.InternalClient!.GetTestHooks(silo); // Test hooks require an initialized client. + return this.InternalClient!.GetTestHooks(silo); } /// - /// Wait for active silos to observe cluster manifest updates for all active silos. + /// Waits for active silos to observe cluster manifest updates for all active silos. /// - /// Whether recent membership changes were done by graceful Stop. public async Task WaitForClusterManifestToStabilizeAsync(bool didKill = false) { var clusterMembershipOptions = this.ServiceProvider.GetRequiredService>().Value; var stabilizationTime = GetLivenessStabilizationTime(clusterMembershipOptions, didKill); var activeSilos = GetActiveSilos().ToArray(); - var testHooks = activeSilos.Select(GetTestHooks).ToArray(); + var testHooks = activeSilos.Select(silo => this.InternalClient!.GetTestHooks(silo)).ToArray(); WriteLog(Environment.NewLine + Environment.NewLine + "WaitForClusterManifestToStabilize is waiting up to {0} for {1} active silo manifest(s)", stabilizationTime, activeSilos.Length); if (await ClusterManifestStabilizationHelper.WaitForExpectedClusterManifestAsync(activeSilos, testHooks, stabilizationTime)) @@ -950,13 +952,18 @@ public async Task InitializeClientAsync(CancellationToken cancellationToken) switch (transport) { case ConnectionTransportType.TcpSocket: + // TCP is used by default break; case ConnectionTransportType.InMemory: - clientBuilder.UseInMemoryConnectionTransport(_transportHub); - break; + { + clientBuilder.UseInMemoryTransport(_transportHub); + break; + } case ConnectionTransportType.UnixSocket: - clientBuilder.UseUnixSocketConnection(); - break; + { + clientBuilder.UseUnixSocketConnection(); + break; + } default: throw new ArgumentException($"Unsupported {nameof(ConnectionTransportType)}: {transport}"); } @@ -1042,11 +1049,15 @@ public async Task DefaultCreateSiloAsync( case ConnectionTransportType.TcpSocket: break; case ConnectionTransportType.InMemory: - siloBuilder.UseInMemoryConnectionTransport(_transportHub); - break; + { + siloBuilder.UseInMemoryTransport(_transportHub); + break; + } case ConnectionTransportType.UnixSocket: - siloBuilder.UseUnixSocketConnection(); - break; + { + siloBuilder.UseUnixSocketConnection(); + break; + } default: throw new ArgumentException($"Unsupported {nameof(ConnectionTransportType)}: {transport}"); } diff --git a/src/Orleans.TestingHost/TestClusterOptions.cs b/src/Orleans.TestingHost/TestClusterOptions.cs index edc0b9887c1..4ac03323154 100644 --- a/src/Orleans.TestingHost/TestClusterOptions.cs +++ b/src/Orleans.TestingHost/TestClusterOptions.cs @@ -99,7 +99,7 @@ public class TestClusterOptions /// Gets or sets a value indicating what transport to use for connecting silos and clients. /// /// - /// Defaults to InMemory. + /// Defaults to the in-memory transport. /// public ConnectionTransportType ConnectionTransport { get; set; } = ConnectionTransportType.InMemory; diff --git a/src/Orleans.TestingHost/UnixSocketTransport/UnixDomainSocketMessageTransportConnector.cs b/src/Orleans.TestingHost/UnixSocketTransport/UnixDomainSocketMessageTransportConnector.cs new file mode 100644 index 00000000000..ab9913d92bc --- /dev/null +++ b/src/Orleans.TestingHost/UnixSocketTransport/UnixDomainSocketMessageTransportConnector.cs @@ -0,0 +1,52 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.Connections.Transport; +using Orleans.Connections.Transport.Sockets; + +namespace Orleans.TestingHost.UnixSocketTransport; + +internal class UnixDomainSocketMessageTransportConnector : MessageTransportConnector +{ + private readonly ILogger _logger; + private readonly IOptions _options; + + public UnixDomainSocketMessageTransportConnector(IOptions options, ILoggerFactory loggerFactory) + { + _logger = loggerFactory.CreateLogger("Orleans.Connections.Transport.Sockets"); + _options = options; + } + + /// + public override IFeatureCollection Features { get; } = new FeatureCollection(); + + /// + public override bool IsValid => Socket.OSSupportsUnixDomainSockets; + + /// + public override async ValueTask CreateAsync(EndPoint endPoint, CancellationToken cancellationToken = default) + { + if (endPoint is not UnixDomainSocketEndPoint unixEndPoint) + { + unixEndPoint = new UnixDomainSocketEndPoint(_options.Value.ConvertEndpointToPath(endPoint)); + } + + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + + try + { + await socket.ConnectAsync(unixEndPoint, cancellationToken).ConfigureAwait(false); + } + catch + { + socket.Dispose(); + throw; + } + + return await SocketMessageTransport.CreateAndStartAsync(socket, _logger).ConfigureAwait(false); + } +} diff --git a/src/Orleans.TestingHost/UnixSocketTransport/UnixDomainSocketMessageTransportListener.cs b/src/Orleans.TestingHost/UnixSocketTransport/UnixDomainSocketMessageTransportListener.cs new file mode 100644 index 00000000000..5d83c40c908 --- /dev/null +++ b/src/Orleans.TestingHost/UnixSocketTransport/UnixDomainSocketMessageTransportListener.cs @@ -0,0 +1,194 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.Connections.Transport; +using Orleans.Connections.Transport.Sockets; + +namespace Orleans.TestingHost.UnixSocketTransport; + +/// +/// Configures a Unix domain socket message transport listener. +/// +public class UnixDomainSocketMessageTransportListenerOptions +{ + /// + /// Gets or sets the socket path. The default is a unique path in the system temporary directory. + /// + public string Path { get; set; } = CreateDefaultPath(); + + /// + /// Gets or sets a value indicating whether the listener is enabled. The default is . + /// + public bool Enabled { get; set; } = true; + private static string CreateDefaultPath() => System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"silo_{Guid.NewGuid():N}"); +} + +internal class UnixDomainSocketMessageTransportListener : MessageTransportListener +{ + private readonly CancellationTokenSource _closingCts = new(); + private Socket? _listenSocket; + private string? _boundPath; + private int _disposeStarted; + private readonly IOptionsMonitor _listenerOptions; + + internal UnixDomainSocketMessageTransportListener( + string endpointName, + IOptionsMonitor listenerOptions, + ILoggerFactory loggerFactory) + { + ListenerName = endpointName; + _listenerOptions = listenerOptions; + Logger = loggerFactory.CreateLogger("Orleans.Connections.Transport.Sockets"); + } + + protected ILogger Logger { get; } + + /// + public override FeatureCollection Features { get; } = new FeatureCollection(); + + /// + public override bool IsValid => Socket.OSSupportsUnixDomainSockets && _listenerOptions.Get(ListenerName).Enabled; + + /// + public override string ListenerName { get; } + + protected virtual Socket CreateListenSocket() + { + var listenSocket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + + return listenSocket; + } + + public override ValueTask BindAsync(CancellationToken cancellationToken = default) + { + if (_listenSocket != null) + { + throw new InvalidOperationException("Transport already bound"); + } + + var listenSocket = CreateListenSocket(); + + var options = _listenerOptions.Get(ListenerName); + var path = options.Path; + var bound = false; + try + { + listenSocket.Bind(new UnixDomainSocketEndPoint(path)); + bound = true; + listenSocket.Listen(512); + } + catch (SocketException e) when (e.SocketErrorCode == SocketError.AddressAlreadyInUse) + { + listenSocket.Dispose(); + throw new AddressInUseException(e.Message, e); + } + catch + { + listenSocket.Dispose(); + if (bound && !string.IsNullOrEmpty(path) && path[0] != '\0') + { + File.Delete(path); + } + + throw; + } + + _boundPath = path; + _listenSocket = listenSocket; + return default; + } + + public override async ValueTask AcceptAsync(CancellationToken cancellationToken = default) + { + var listenSocket = _listenSocket; + if (_closingCts.IsCancellationRequested) + { + return null; + } + + if (listenSocket is null) + { + throw new InvalidOperationException("Transport is not bound"); + } + + using var ct = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _closingCts.Token); + while (!ct.IsCancellationRequested) + { + try + { + var acceptSocket = await listenSocket.AcceptAsync(ct.Token).ConfigureAwait(false); + return await SocketMessageTransport.CreateAndStartAsync(acceptSocket, Logger).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Graceful termination. + return null; + } + catch (ObjectDisposedException) + { + // A call was made to UnbindAsync/DisposeAsync just return null which signals we're done + return null; + } + catch (SocketException e) when (e.SocketErrorCode == SocketError.OperationAborted) + { + // A call was made to UnbindAsync/DisposeAsync just return null which signals we're done + return null; + } + catch (SocketException) + { + // The connection got reset while it was in the backlog, so we try again. + SocketsLog.ConnectionReset(Logger, connection: "(null)"); + } + } + + return null; + } + + private void DisposeCore() + { + _closingCts.Cancel(); + _listenSocket?.Dispose(); + _listenSocket = null; + + var path = _boundPath; + _boundPath = null; + if (!string.IsNullOrEmpty(path) && path[0] != '\0') + { + File.Delete(path); + } + } + + public override ValueTask UnbindAsync(CancellationToken cancellationToken) + { + if (Volatile.Read(ref _disposeStarted) == 0) + { + DisposeCore(); + } + + return default; + } + + public override async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) + { + return; + } + + DisposeCore(); + GC.SuppressFinalize(this); + try + { + await base.DisposeAsync(); + } + finally + { + _closingCts.Dispose(); + } + } +} diff --git a/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionExtensions.cs b/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionExtensions.cs index f70b65f8671..5901c09dd25 100644 --- a/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionExtensions.cs +++ b/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionExtensions.cs @@ -1,8 +1,11 @@ using System; -using Microsoft.AspNetCore.Connections; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans.Configuration; +using Orleans.Connections.Transport; using Orleans.Hosting; -using Orleans.Runtime; using Orleans.Runtime.Messaging; namespace Orleans.TestingHost.UnixSocketTransport; @@ -19,13 +22,14 @@ public static class UnixSocketConnectionExtensions /// The provided silo builder. public static ISiloBuilder UseUnixSocketConnection(this ISiloBuilder siloBuilder) { - siloBuilder.ConfigureServices(services => - { - services.AddKeyedSingleton(SiloConnectionFactory.ServicesKey, CreateUnixSocketConnectionFactory()); - services.AddKeyedSingleton(SiloConnectionListener.ServicesKey, CreateUnixSocketConnectionListenerFactory()); - services.AddKeyedSingleton(GatewayConnectionListener.ServicesKey, CreateUnixSocketConnectionListenerFactory()); - }); - + var services = siloBuilder.Services; + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(sp => new UnixDomainSocketMessageTransportConnector( + sp.GetRequiredService>(), + sp.GetRequiredService())); + AddListener(services, GatewayConnectionListener.DefaultListenerName, static options => options.GetListeningProxyEndpoint()); + AddListener(services, SiloConnectionListener.DefaultListenerName, static options => options.GetListeningSiloEndpoint()); return siloBuilder; } @@ -36,21 +40,31 @@ public static ISiloBuilder UseUnixSocketConnection(this ISiloBuilder siloBuilder /// The provided client builder. public static IClientBuilder UseUnixSocketConnection(this IClientBuilder clientBuilder) { - clientBuilder.ConfigureServices(services => - { - services.AddKeyedSingleton(ClientOutboundConnectionFactory.ServicesKey, CreateUnixSocketConnectionFactory()); - }); - + clientBuilder.Services.RemoveAll(); + clientBuilder.Services.AddSingleton(sp => new UnixDomainSocketMessageTransportConnector( + sp.GetRequiredService>(), + sp.GetRequiredService())); return clientBuilder; } - private static Func CreateUnixSocketConnectionFactory() - { - return (IServiceProvider sp, object? key) => ActivatorUtilities.CreateInstance(sp); - } - - private static Func CreateUnixSocketConnectionListenerFactory() + private static void AddListener( + IServiceCollection services, + string listenerName, + Func getEndpoint) { - return (IServiceProvider sp, object? key) => ActivatorUtilities.CreateInstance(sp); + services.AddSingleton(sp => new UnixDomainSocketMessageTransportListener( + listenerName, + sp.GetRequiredService>(), + sp.GetRequiredService())); + services.AddOptions(listenerName) + .Configure, IOptions>((listenerOptions, endpointOptions, connectionOptions) => + { + var endpoint = getEndpoint(endpointOptions.Value); + listenerOptions.Enabled = endpoint is not null; + if (endpoint is not null) + { + listenerOptions.Path = connectionOptions.Value.ConvertEndpointToPath(endpoint); + } + }); } } diff --git a/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionFactory.cs b/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionFactory.cs deleted file mode 100644 index 0fa98506b43..00000000000 --- a/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionFactory.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Buffers; -using System.Net; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Orleans.Networking.Shared; - -namespace Orleans.TestingHost.UnixSocketTransport; - -internal class UnixSocketConnectionFactory : IConnectionFactory -{ - private readonly SocketsTrace trace; - private readonly UnixSocketConnectionOptions socketConnectionOptions; - private readonly SocketSchedulers schedulers; - private readonly MemoryPool memoryPool; - - public UnixSocketConnectionFactory( - ILoggerFactory loggerFactory, - IOptions options, - SocketSchedulers schedulers, - SharedMemoryPool memoryPool) - { - var logger = loggerFactory.CreateLogger("Orleans.UnixSocket"); - this.trace = new SocketsTrace(logger); - this.socketConnectionOptions = options.Value; - this.schedulers = schedulers; - this.memoryPool = memoryPool.Pool; - } - - public async ValueTask ConnectAsync(EndPoint endpoint, CancellationToken cancellationToken = default) - { - var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); - var unixEndpoint = new UnixDomainSocketEndPoint(socketConnectionOptions.ConvertEndpointToPath(endpoint)); - await socket.ConnectAsync(unixEndpoint); - var scheduler = this.schedulers.GetScheduler(); - var connection = new SocketConnection(socket, memoryPool, scheduler, trace); - connection.Start(); - return connection; - } -} diff --git a/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionListener.cs b/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionListener.cs deleted file mode 100644 index a3fa0a372d3..00000000000 --- a/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionListener.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Buffers; -using System.Net; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Orleans.Networking.Shared; - -namespace Orleans.TestingHost.UnixSocketTransport; - -internal class UnixSocketConnectionListener : IConnectionListener -{ - private readonly UnixDomainSocketEndPoint _unixEndpoint; - private readonly EndPoint _endpoint; - private readonly UnixSocketConnectionOptions _socketConnectionOptions; - private readonly SocketsTrace _trace; - private readonly SocketSchedulers _schedulers; - private readonly MemoryPool _memoryPool; - private Socket _listenSocket = null!; - - public UnixSocketConnectionListener(UnixDomainSocketEndPoint unixEndpoint, EndPoint endpoint, UnixSocketConnectionOptions socketConnectionOptions, SocketsTrace trace, SocketSchedulers schedulers) - { - _unixEndpoint = unixEndpoint; - _endpoint = endpoint; - _socketConnectionOptions = socketConnectionOptions; - _trace = trace; - _schedulers = schedulers; - _memoryPool = socketConnectionOptions.MemoryPoolFactory(); - } - - public EndPoint EndPoint => _endpoint; - - public void Bind() - { - _listenSocket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); - _listenSocket.Bind(_unixEndpoint); - _listenSocket.Listen(512); - } - - public async ValueTask AcceptAsync(CancellationToken cancellationToken = default) - { - while (true) - { - try - { - var acceptSocket = await _listenSocket.AcceptAsync(cancellationToken); - - var connection = new SocketConnection(acceptSocket, _memoryPool, _schedulers.GetScheduler(), _trace); - - connection.Start(); - - return connection; - } - catch (ObjectDisposedException) - { - // A call was made to UnbindAsync/DisposeAsync just return null which signals we're done - return null; - } - catch (SocketException e) when (e.SocketErrorCode == SocketError.OperationAborted) - { - // A call was made to UnbindAsync/DisposeAsync just return null which signals we're done - return null; - } - catch (SocketException) - { - // The connection got reset while it was in the backlog, so we try again. - _trace.ConnectionReset(connectionId: "(null)"); - } - } - } - - public ValueTask DisposeAsync() - { - _listenSocket?.Dispose(); - return default; - } - - public ValueTask UnbindAsync(CancellationToken cancellationToken = default) - { - _listenSocket?.Dispose(); - _memoryPool?.Dispose(); - return default; - } -} diff --git a/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionListenerFactory.cs b/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionListenerFactory.cs deleted file mode 100644 index b11986818af..00000000000 --- a/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionListenerFactory.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Orleans.Networking.Shared; - -namespace Orleans.TestingHost.UnixSocketTransport; - -internal class UnixSocketConnectionListenerFactory : IConnectionListenerFactory -{ - private readonly UnixSocketConnectionOptions socketConnectionOptions; - private readonly SocketsTrace trace; - private readonly SocketSchedulers schedulers; - - public UnixSocketConnectionListenerFactory( - ILoggerFactory loggerFactory, - IOptions socketConnectionOptions, - SocketSchedulers schedulers) - { - this.socketConnectionOptions = socketConnectionOptions.Value; - var logger = loggerFactory.CreateLogger("Orleans.UnixSockets"); - this.trace = new SocketsTrace(logger); - this.schedulers = schedulers; - } - - public ValueTask BindAsync(EndPoint endpoint, CancellationToken cancellationToken = default) - { - var unixEndpoint = new UnixDomainSocketEndPoint(socketConnectionOptions.ConvertEndpointToPath(endpoint)); - - var listener = new UnixSocketConnectionListener(unixEndpoint, endpoint, this.socketConnectionOptions, this.trace, this.schedulers); - listener.Bind(); - return new ValueTask(listener); - } -} diff --git a/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionOptions.cs b/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionOptions.cs index a109ce8917b..ec3d44d0e84 100644 --- a/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionOptions.cs +++ b/src/Orleans.TestingHost/UnixSocketTransport/UnixSocketConnectionOptions.cs @@ -1,9 +1,7 @@ using System; -using System.Buffers; using System.IO; using System.Net; using System.Text.RegularExpressions; -using Orleans.Networking.Shared; namespace Orleans.TestingHost.UnixSocketTransport; @@ -17,11 +15,6 @@ public partial class UnixSocketConnectionOptions /// public Func ConvertEndpointToPath { get; set; } = DefaultConvertEndpointToPath; - /// - /// Gets or sets the memory pool factory. - /// - internal Func> MemoryPoolFactory { get; set; } = () => KestrelMemoryPool.Create(); - [GeneratedRegex("[^a-zA-Z0-9]")] private static partial Regex ConvertEndpointRegex(); diff --git a/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs b/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs deleted file mode 100644 index f0e3ede6834..00000000000 --- a/src/api/Orleans.Connections.Security/Orleans.Connections.Security.cs +++ /dev/null @@ -1,147 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ -namespace Orleans -{ - public static partial class TlsConnectionBuilderExtensions - { - public static void UseClientTls(this Microsoft.AspNetCore.Connections.IConnectionBuilder builder, Connections.Security.TlsOptions options) { } - - public static void UseServerTls(this Microsoft.AspNetCore.Connections.IConnectionBuilder builder, Connections.Security.TlsOptions options) { } - } -} - -namespace Orleans.Connections.Security -{ - public static partial class CertificateLoader - { - public static System.Security.Cryptography.X509Certificates.X509Certificate2 LoadFromStoreCert(string subject, string storeName, System.Security.Cryptography.X509Certificates.StoreLocation storeLocation, bool allowInvalid, bool server) { throw null; } - } - - public delegate System.Security.Cryptography.X509Certificates.X509Certificate? ClientCertificateSelectionCallback(object sender, string targetHost, System.Security.Cryptography.X509Certificates.X509CertificateCollection localCertificates, System.Security.Cryptography.X509Certificates.X509Certificate? remoteCertificate, string[] acceptableIssuers); - public partial interface ITlsApplicationProtocolFeature - { - System.ReadOnlyMemory ApplicationProtocol { get; } - } - - public partial interface ITlsConnectionFeature - { - System.Security.Cryptography.X509Certificates.X509Certificate2? RemoteCertificate { get; set; } - - System.Threading.Tasks.Task GetRemoteCertificateAsync(System.Threading.CancellationToken cancellationToken); - } - - public partial interface ITlsHandshakeFeature - { - System.Security.Authentication.CipherAlgorithmType CipherAlgorithm { get; } - - int CipherStrength { get; } - - System.Security.Authentication.HashAlgorithmType HashAlgorithm { get; } - - int HashStrength { get; } - - string HostName { get; } - - System.Security.Authentication.ExchangeAlgorithmType KeyExchangeAlgorithm { get; } - - int KeyExchangeStrength { get; } - - System.Net.Security.TlsCipherSuite? NegotiatedCipherSuite { get; } - - System.Security.Authentication.SslProtocols Protocol { get; } - } - - public enum RemoteCertificateMode - { - NoCertificate = 0, - AllowCertificate = 1, - RequireCertificate = 2 - } - - public delegate bool RemoteCertificateValidator(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, System.Security.Cryptography.X509Certificates.X509Chain? chain, System.Net.Security.SslPolicyErrors policyErrors); - public delegate System.Security.Cryptography.X509Certificates.X509Certificate ServerCertificateSelectionCallback(object sender, string? hostName); - public partial class TlsClientAuthenticationOptions - { - public System.Security.Cryptography.X509Certificates.X509RevocationMode CertificateRevocationCheckMode { get { throw null; } set { } } - - public System.Security.Cryptography.X509Certificates.X509CertificateCollection? ClientCertificates { get { throw null; } set { } } - - public System.Security.Authentication.SslProtocols EnabledSslProtocols { get { throw null; } set { } } - - public ClientCertificateSelectionCallback? LocalCertificateSelectionCallback { get { throw null; } set { } } - - public object SslClientAuthenticationOptions { get { throw null; } } - - public string? TargetHost { get { throw null; } set { } } - } - - public partial class TlsOptions - { - public bool CheckCertificateRevocation { get { throw null; } set { } } - - public RemoteCertificateMode ClientCertificateMode { get { throw null; } set { } } - - public System.TimeSpan HandshakeTimeout { get { throw null; } set { } } - - public System.Security.Cryptography.X509Certificates.X509Certificate2? LocalCertificate { get { throw null; } set { } } - - public System.Func? LocalClientCertificateSelector { get { throw null; } set { } } - - public System.Func? LocalServerCertificateSelector { get { throw null; } set { } } - - public System.Action? OnAuthenticateAsClient { get { throw null; } set { } } - - public System.Action? OnAuthenticateAsServer { get { throw null; } set { } } - - public RemoteCertificateMode RemoteCertificateMode { get { throw null; } set { } } - - public RemoteCertificateValidator? RemoteCertificateValidation { get { throw null; } set { } } - - public System.Security.Authentication.SslProtocols SslProtocols { get { throw null; } set { } } - - public void AllowAnyRemoteCertificate() { } - } - - public partial class TlsServerAuthenticationOptions - { - public System.Security.Cryptography.X509Certificates.X509RevocationMode CertificateRevocationCheckMode { get { throw null; } set { } } - - public bool ClientCertificateRequired { get { throw null; } set { } } - - public System.Security.Authentication.SslProtocols EnabledSslProtocols { get { throw null; } set { } } - - public System.Security.Cryptography.X509Certificates.X509Certificate? ServerCertificate { get { throw null; } set { } } - - public ServerCertificateSelectionCallback? ServerCertificateSelectionCallback { get { throw null; } set { } } - - public object SslServerAuthenticationOptions { get { throw null; } } - } -} - -namespace Orleans.Hosting -{ - public static partial class OrleansConnectionSecurityHostingExtensions - { - public static IClientBuilder UseTls(this IClientBuilder builder, System.Action configureOptions) { throw null; } - - public static IClientBuilder UseTls(this IClientBuilder builder, System.Security.Cryptography.X509Certificates.StoreName storeName, string subject, bool allowInvalid, System.Security.Cryptography.X509Certificates.StoreLocation location, System.Action configureOptions) { throw null; } - - public static IClientBuilder UseTls(this IClientBuilder builder, System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, System.Action configureOptions) { throw null; } - - public static IClientBuilder UseTls(this IClientBuilder builder, System.Security.Cryptography.X509Certificates.X509Certificate2 certificate) { throw null; } - - public static ISiloBuilder UseTls(this ISiloBuilder builder, System.Action configureOptions) { throw null; } - - public static ISiloBuilder UseTls(this ISiloBuilder builder, System.Security.Cryptography.X509Certificates.StoreName storeName, string subject, bool allowInvalid, System.Security.Cryptography.X509Certificates.StoreLocation location, System.Action configureOptions) { throw null; } - - public static ISiloBuilder UseTls(this ISiloBuilder builder, System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, System.Action configureOptions) { throw null; } - - public static ISiloBuilder UseTls(this ISiloBuilder builder, System.Security.Cryptography.X509Certificates.X509Certificate2 certificate) { throw null; } - } -} \ No newline at end of file diff --git a/src/api/Orleans.Core/Orleans.Core.cs b/src/api/Orleans.Core/Orleans.Core.cs index b7276042714..19c3f77b224 100644 --- a/src/api/Orleans.Core/Orleans.Core.cs +++ b/src/api/Orleans.Core/Orleans.Core.cs @@ -87,14 +87,6 @@ public CollectionAgeLimitAttribute(string inactivityPeriod) { } public void Populate(System.IServiceProvider services, System.Type grainClass, Runtime.GrainType grainType, System.Collections.Generic.Dictionary properties) { } } - public static partial class ConnectionMiddlewareExtensions - { - public static Microsoft.AspNetCore.Connections.IConnectionBuilder UseMiddleware(this Microsoft.AspNetCore.Connections.IConnectionBuilder builder, Runtime.Messaging.IConnectionMiddleware middleware) { throw null; } - - public static Microsoft.AspNetCore.Connections.IConnectionBuilder UseMiddleware(this Microsoft.AspNetCore.Connections.IConnectionBuilder builder) - where T : Runtime.Messaging.IConnectionMiddleware { throw null; } - } - public delegate void ConnectionToClusterLostHandler(object? sender, System.EventArgs e); public delegate TInstance Factory(); public delegate TInstance Factory(TParam1 param1); @@ -441,11 +433,6 @@ public TableVersion(int version, string eTag) { } namespace Orleans.Configuration { - public partial class ClientConnectionOptions - { - public void ConfigureConnection(System.Action configure) { } - } - public partial class ClientMessagingOptions : MessagingOptions { public const int DEFAULT_CLIENT_SENDER_BUCKETS = 8192; @@ -522,7 +509,10 @@ public void ValidateConfiguration() { } public partial class ConnectionOptions { + public static readonly System.TimeSpan DEFAULT_CLOSECONNECTION_TIMEOUT; public static readonly System.TimeSpan DEFAULT_OPENCONNECTION_TIMEOUT; + public System.TimeSpan CloseConnectionTimeout { get { throw null; } set { } } + public System.TimeSpan ConnectionRetryDelay { get { throw null; } set { } } public int ConnectionsPerEndpoint { get { throw null; } set { } } @@ -649,6 +639,261 @@ public static partial class OptionsOverrides } } +namespace Orleans.Connections.Transport +{ + public partial class ConnectionAbortedException : System.Exception + { + public ConnectionAbortedException() { } + + [System.Obsolete] + protected ConnectionAbortedException(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) { } + + public ConnectionAbortedException(string? message, System.Exception? innerException) { } + + public ConnectionAbortedException(string? message) { } + } + + public partial class ConnectionClosedException : System.Exception + { + public ConnectionClosedException() { } + + [System.Obsolete] + protected ConnectionClosedException(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) { } + + public ConnectionClosedException(string? message, System.Exception? innerException) { } + + public ConnectionClosedException(string? message) { } + } + + public partial class ConnectionResetException : System.Exception + { + public ConnectionResetException() { } + + [System.Obsolete] + protected ConnectionResetException(System.Runtime.Serialization.SerializationInfo info, System.Runtime.Serialization.StreamingContext context) { } + + public ConnectionResetException(string? message, System.Exception? innerException) { } + + public ConnectionResetException(string? message) { } + } + + public partial class FeatureCollection : IFeatureCollection, System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable + { + public FeatureCollection() { } + + public FeatureCollection(IFeatureCollection defaults) { } + + public FeatureCollection(int initialCapacity) { } + + public bool IsReadOnly { get { throw null; } } + + public object? this[System.Type key] { get { throw null; } set { } } + + public virtual int Revision { get { throw null; } } + + public TFeature? Get() { throw null; } + + public System.Collections.Generic.IEnumerator> GetEnumerator() { throw null; } + + public void Set(TFeature? instance) { } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; } + } + + public partial interface IConnectionEndPointFeature + { + System.Net.EndPoint? LocalEndPoint { get; set; } + + System.Net.EndPoint? RemoteEndPoint { get; set; } + } + + public partial interface IFeatureCollection : System.Collections.Generic.IEnumerable>, System.Collections.IEnumerable + { + bool IsReadOnly { get; } + + object? this[System.Type key] { get; set; } + + int Revision { get; } + + TFeature? Get(); + void Set(TFeature? instance); + } + + public partial interface IMessageTransportConnectorMiddleware + { + MessageTransportConnector Apply(MessageTransportConnector transport); + } + + public partial interface IMessageTransportListenerMiddleware + { + MessageTransportListener Apply(MessageTransportListener listener); + } + + public abstract partial class MessageTransport : System.IAsyncDisposable + { + public abstract System.Threading.CancellationToken Closed { get; } + public abstract IFeatureCollection Features { get; } + + public abstract System.Threading.Tasks.ValueTask CloseAsync(System.Exception? closeException, System.Threading.CancellationToken cancellationToken = default); + public virtual System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } + + public abstract bool EnqueueRead(ReadRequest request); + public abstract bool EnqueueWrite(WriteRequest request); + } + + public abstract partial class MessageTransportBase : MessageTransport + { + public override FeatureCollection Features { get { throw null; } } + } + + public abstract partial class MessageTransportConnector : System.IAsyncDisposable + { + public abstract IFeatureCollection Features { get; } + public abstract bool IsValid { get; } + + public abstract System.Threading.Tasks.ValueTask CreateAsync(System.Net.EndPoint endpoint, System.Threading.CancellationToken cancellationToken = default); + public virtual System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } + } + + public abstract partial class MessageTransportListener : System.IAsyncDisposable + { + public abstract IFeatureCollection Features { get; } + public abstract bool IsValid { get; } + public abstract string ListenerName { get; } + + public abstract System.Threading.Tasks.ValueTask AcceptAsync(System.Threading.CancellationToken cancellationToken = default); + public abstract System.Threading.Tasks.ValueTask BindAsync(System.Threading.CancellationToken cancellationToken = default); + public virtual System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } + + public abstract System.Threading.Tasks.ValueTask UnbindAsync(System.Threading.CancellationToken cancellationToken = default); + } + + public abstract partial class ReadRequest + { + public abstract void OnCanceled(); + public abstract void OnError(System.Exception error); + public abstract bool OnRead(Serialization.Buffers.ArcBufferReader buffer); + } + + public abstract partial class WriteRequest + { + public Serialization.Buffers.ArcBufferReader Buffers { get { throw null; } protected set { } } + + public abstract void SetException(System.Exception error); + public abstract void SetResult(); + } +} + +namespace Orleans.Connections.Transport.Security +{ + public static partial class CertificateLoader + { + public static System.Security.Cryptography.X509Certificates.X509Certificate2 LoadFromStoreCert(string subject, string storeName, System.Security.Cryptography.X509Certificates.StoreLocation storeLocation, bool allowInvalid, bool server) { throw null; } + } + + public delegate System.Security.Cryptography.X509Certificates.X509Certificate? ClientCertificateSelectionCallback(object sender, string targetHost, System.Security.Cryptography.X509Certificates.X509CertificateCollection localCertificates, System.Security.Cryptography.X509Certificates.X509Certificate? remoteCertificate, string[] acceptableIssuers); + public partial interface ITlsApplicationProtocolFeature + { + System.ReadOnlyMemory ApplicationProtocol { get; } + } + + public partial interface ITlsConnectionFeature + { + System.Security.Cryptography.X509Certificates.X509Certificate2? RemoteCertificate { get; set; } + + System.Threading.Tasks.Task GetRemoteCertificateAsync(System.Threading.CancellationToken cancellationToken); + } + + public partial interface ITlsHandshakeFeature + { + System.Security.Authentication.CipherAlgorithmType CipherAlgorithm { get; } + + int CipherStrength { get; } + + System.Security.Authentication.HashAlgorithmType HashAlgorithm { get; } + + int HashStrength { get; } + + string HostName { get; } + + System.Security.Authentication.ExchangeAlgorithmType KeyExchangeAlgorithm { get; } + + int KeyExchangeStrength { get; } + + System.Net.Security.TlsCipherSuite? NegotiatedCipherSuite { get; } + + System.Security.Authentication.SslProtocols Protocol { get; } + } + + public enum RemoteCertificateMode + { + NoCertificate = 0, + AllowCertificate = 1, + RequireCertificate = 2 + } + + public delegate bool RemoteCertificateValidator(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, System.Security.Cryptography.X509Certificates.X509Chain? chain, System.Net.Security.SslPolicyErrors policyErrors); + public delegate System.Security.Cryptography.X509Certificates.X509Certificate? ServerCertificateSelectionCallback(object sender, string? hostName); + public partial class TlsClientAuthenticationOptions + { + public System.Security.Cryptography.X509Certificates.X509RevocationMode CertificateRevocationCheckMode { get { throw null; } set { } } + + public System.Security.Cryptography.X509Certificates.X509CertificateCollection? ClientCertificates { get { throw null; } set { } } + + public System.Security.Authentication.SslProtocols EnabledSslProtocols { get { throw null; } set { } } + + public ClientCertificateSelectionCallback? LocalCertificateSelectionCallback { get { throw null; } set { } } + + public object SslClientAuthenticationOptions { get { throw null; } } + + public string? TargetHost { get { throw null; } set { } } + } + + public partial class TlsOptions + { + public bool CheckCertificateRevocation { get { throw null; } set { } } + + public RemoteCertificateMode ClientCertificateMode { get { throw null; } set { } } + + public System.TimeSpan HandshakeTimeout { get { throw null; } set { } } + + public System.Security.Cryptography.X509Certificates.X509Certificate2? LocalCertificate { get { throw null; } set { } } + + public System.Func? LocalClientCertificateSelector { get { throw null; } set { } } + + public System.Func? LocalServerCertificateSelector { get { throw null; } set { } } + + public System.Buffers.MemoryPool MemoryPool { get { throw null; } set { } } + + public System.Action? OnAuthenticateAsClient { get { throw null; } set { } } + + public System.Action? OnAuthenticateAsServer { get { throw null; } set { } } + + public RemoteCertificateMode RemoteCertificateMode { get { throw null; } set { } } + + public RemoteCertificateValidator? RemoteCertificateValidation { get { throw null; } set { } } + + public System.Security.Authentication.SslProtocols SslProtocols { get { throw null; } set { } } + + public void AllowAnyRemoteCertificate() { } + } + + public partial class TlsServerAuthenticationOptions + { + public System.Security.Cryptography.X509Certificates.X509RevocationMode CertificateRevocationCheckMode { get { throw null; } set { } } + + public bool ClientCertificateRequired { get { throw null; } set { } } + + public System.Security.Authentication.SslProtocols EnabledSslProtocols { get { throw null; } set { } } + + public System.Security.Cryptography.X509Certificates.X509Certificate? ServerCertificate { get { throw null; } set { } } + + public ServerCertificateSelectionCallback? ServerCertificateSelectionCallback { get { throw null; } set { } } + + public object SslServerAuthenticationOptions { get { throw null; } } + } +} + namespace Orleans.Core.Diagnostics { public static partial class ClientLifecycleEvents @@ -851,6 +1096,17 @@ public static IClientBuilder AddOutgoingGrainCallFilter(this IC where TImplementation : class, IOutgoingGrainCallFilter { throw null; } } + public static partial class ClientTlsHostingExtensions + { + public static IClientBuilder UseTls(this IClientBuilder builder, System.Action configureOptions) { throw null; } + + public static IClientBuilder UseTls(this IClientBuilder builder, System.Security.Cryptography.X509Certificates.StoreName storeName, string subject, bool allowInvalid, System.Security.Cryptography.X509Certificates.StoreLocation location, System.Action configureOptions) { throw null; } + + public static IClientBuilder UseTls(this IClientBuilder builder, System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, System.Action configureOptions) { throw null; } + + public static IClientBuilder UseTls(this IClientBuilder builder, System.Security.Cryptography.X509Certificates.X509Certificate2 certificate) { throw null; } + } + public static partial class GrainCallFilterServiceCollectionExtensions { [System.Obsolete("Use ISiloBuilder.AddIncomingGrainCallFilter", true)] @@ -1071,35 +1327,6 @@ public GrainTypeResolver(System.Collections.Generic.IEnumerable ReadFrameAsync(Microsoft.AspNetCore.Connections.ConnectionContext connection, System.Threading.CancellationToken cancellationToken, int maxFrameLength = 1048576) { throw null; } - - public static string ReadLengthPrefixedString(byte[] data, ref int offset) { throw null; } - - public static System.Threading.Tasks.ValueTask WriteFrameAsync(Microsoft.AspNetCore.Connections.ConnectionContext connection, byte frameType, System.Action> writePayload, System.Threading.CancellationToken cancellationToken) { throw null; } - - public static System.Threading.Tasks.ValueTask WriteFrameAsync(Microsoft.AspNetCore.Connections.ConnectionContext connection, byte frameType, byte[] payload, System.Threading.CancellationToken cancellationToken) { throw null; } - - public static void WriteLengthPrefixedString(System.Buffers.IBufferWriter writer, string value) { } - } - - public partial interface IConnectionMiddleware - { - System.Threading.Tasks.Task OnConnectionAsync(Microsoft.AspNetCore.Connections.ConnectionContext context, Microsoft.AspNetCore.Connections.ConnectionDelegate next); - } - [GenerateSerializer] public sealed partial class InvalidMessageFrameException : OrleansException { @@ -3810,35 +4017,6 @@ public void WriteField(ref global::Orleans.Serialization.Buffers. } } -namespace OrleansCodeGen.Orleans.Networking.Shared -{ - [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - public sealed partial class Codec_SocketConnectionException : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec - { - public Codec_SocketConnectionException(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider, global::Orleans.Serialization.Activators.IActivator _activator) { } - - public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Networking.Shared.SocketConnectionException instance) { } - - public global::Orleans.Networking.Shared.SocketConnectionException ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; } - - public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, global::Orleans.Networking.Shared.SocketConnectionException instance) - where TBufferWriter : System.Buffers.IBufferWriter { } - - public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, global::Orleans.Networking.Shared.SocketConnectionException value) - where TBufferWriter : System.Buffers.IBufferWriter { } - } - - [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] - [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - public sealed partial class Copier_SocketConnectionException : global::Orleans.Serialization.GeneratedCodeHelpers.OrleansGeneratedCodeHelper.ExceptionCopier - { - public Copier_SocketConnectionException(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) : base(default(Serialization.Serializers.ICodecProvider)!) { } - } -} - namespace OrleansCodeGen.Orleans.Placement.Rebalancing { [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")] diff --git a/src/api/Orleans.Runtime/Orleans.Runtime.cs b/src/api/Orleans.Runtime/Orleans.Runtime.cs index e16fbd83f52..51c79d7d4fa 100644 --- a/src/api/Orleans.Runtime/Orleans.Runtime.cs +++ b/src/api/Orleans.Runtime/Orleans.Runtime.cs @@ -248,28 +248,6 @@ public partial class SchedulingOptions public System.TimeSpan TurnWarningLengthThreshold { get { throw null; } set { } } } - public partial class SiloConnectionOptions : SiloConnectionOptions.ISiloConnectionBuilderOptions - { - public void ConfigureGatewayInboundConnection(System.Action configure) { } - - public void ConfigureSiloInboundConnection(System.Action configure) { } - - public void ConfigureSiloOutboundConnection(System.Action configure) { } - - void ISiloConnectionBuilderOptions.ConfigureGatewayInboundBuilder(Microsoft.AspNetCore.Connections.IConnectionBuilder builder) { } - - void ISiloConnectionBuilderOptions.ConfigureSiloInboundBuilder(Microsoft.AspNetCore.Connections.IConnectionBuilder builder) { } - - void ISiloConnectionBuilderOptions.ConfigureSiloOutboundBuilder(Microsoft.AspNetCore.Connections.IConnectionBuilder builder) { } - - public partial interface ISiloConnectionBuilderOptions - { - void ConfigureGatewayInboundBuilder(Microsoft.AspNetCore.Connections.IConnectionBuilder builder); - void ConfigureSiloInboundBuilder(Microsoft.AspNetCore.Connections.IConnectionBuilder builder); - void ConfigureSiloOutboundBuilder(Microsoft.AspNetCore.Connections.IConnectionBuilder builder); - } - } - public partial class SiloMessagingOptions : MessagingOptions { public static readonly System.TimeSpan DEFAULT_CLIENT_GW_NOTIFICATION_TIMEOUT; @@ -514,6 +492,17 @@ public static ISiloBuilder AddStartupTask(this ISiloBuilder builder, i where TStartup : class, Runtime.IStartupTask { throw null; } } + public static partial class SiloTlsHostingExtensions + { + public static ISiloBuilder UseTls(this ISiloBuilder builder, System.Action configureOptions) { throw null; } + + public static ISiloBuilder UseTls(this ISiloBuilder builder, System.Security.Cryptography.X509Certificates.StoreName storeName, string subject, bool allowInvalid, System.Security.Cryptography.X509Certificates.StoreLocation location, System.Action configureOptions) { throw null; } + + public static ISiloBuilder UseTls(this ISiloBuilder builder, System.Security.Cryptography.X509Certificates.X509Certificate2 certificate, System.Action configureOptions) { throw null; } + + public static ISiloBuilder UseTls(this ISiloBuilder builder, System.Security.Cryptography.X509Certificates.X509Certificate2 certificate) { throw null; } + } + public static partial class SystemTextJsonSerializerExtensions { public static ISiloBuilder UseSystemTextJsonGrainStorageSerializer(this ISiloBuilder siloBuilder) { throw null; } diff --git a/src/api/Orleans.Serialization/Orleans.Serialization.cs b/src/api/Orleans.Serialization/Orleans.Serialization.cs index 04e673cb8a2..2bb04e2dd09 100644 --- a/src/api/Orleans.Serialization/Orleans.Serialization.cs +++ b/src/api/Orleans.Serialization/Orleans.Serialization.cs @@ -762,6 +762,8 @@ public readonly partial struct ArcBufferReader { private readonly object _dummy; private readonly int _dummyPrimitive; + public ArcBufferReader(ArcBufferWriter writer) { } + public int Length { get { throw null; } } public readonly void Consume(System.Span output) { } @@ -784,11 +786,13 @@ public readonly void Skip(int count) { } [Immutable] public sealed partial class ArcBufferWriter : System.Buffers.IBufferWriter, System.IDisposable { - public const int MinimumPageSize = 1024; + public const int MinimumPageSize = 16384; public int Length { get { throw null; } } public ArcBufferReader Reader { get { throw null; } } + public int UnconsumedLength { get { throw null; } } + public void AdvanceReader(int count) { } public void AdvanceWriter(int count) { } diff --git a/src/api/Orleans.TestingHost/Orleans.TestingHost.cs b/src/api/Orleans.TestingHost/Orleans.TestingHost.cs index 4435f56a298..5dcf55ab904 100644 --- a/src/api/Orleans.TestingHost/Orleans.TestingHost.cs +++ b/src/api/Orleans.TestingHost/Orleans.TestingHost.cs @@ -86,14 +86,14 @@ public InProcessTestCluster(InProcessTestClusterOptions options, ITestClusterPor public IClusterClient Client { get { throw null; } } - public bool ContainsSilo(Runtime.SiloAddress siloAddress) { throw null; } - public InProcessTestClusterOptions Options { get { throw null; } } public ITestClusterPortAllocator PortAllocator { get { throw null; } } public System.Collections.ObjectModel.ReadOnlyCollection Silos { get { throw null; } } + public bool ContainsSilo(Runtime.SiloAddress siloAddress) { throw null; } + public System.Threading.Tasks.Task CreateSiloAsync(InProcessTestSiloSpecificOptions siloOptions, System.Threading.CancellationToken cancellationToken) { throw null; } public System.Threading.Tasks.Task CreateSiloAsync(InProcessTestSiloSpecificOptions siloOptions) { throw null; } @@ -379,8 +379,6 @@ public TestCluster(TestClusterOptions options, System.Collections.Generic.IReadO public IClusterClient Client { get { throw null; } } - public bool ContainsSilo(Runtime.SiloAddress siloAddress) { throw null; } - public System.Collections.Generic.IReadOnlyList ConfigurationSources { get { throw null; } } public System.Func> CreateSiloAsync { set { } } @@ -401,6 +399,8 @@ public TestCluster(TestClusterOptions options, System.Collections.Generic.IReadO public System.Collections.ObjectModel.ReadOnlyCollection Silos { get { throw null; } } + public bool ContainsSilo(Runtime.SiloAddress siloAddress) { throw null; } + public System.Threading.Tasks.Task DeactivateAsync(Runtime.GrainId grainId) { throw null; } public System.Threading.Tasks.Task DeactivateAsync(Runtime.IAddressable grain) { throw null; } @@ -518,12 +518,12 @@ public TestClusterBuilder AddSiloBuilderConfigurator() public TestCluster Build() { throw null; } - public bool ContainsSilo(Runtime.SiloAddress siloAddress) { throw null; } - public TestClusterBuilder ConfigureBuilder(System.Action configureDelegate) { throw null; } public TestClusterBuilder ConfigureHostConfiguration(System.Action configureDelegate) { throw null; } + public bool ContainsSilo(Runtime.SiloAddress siloAddress) { throw null; } + public static string CreateClusterId() { throw null; } } @@ -821,6 +821,13 @@ public LogEntry(System.DateTimeOffset Timestamp, Microsoft.Extensions.Logging.Lo namespace Orleans.TestingHost.UnixSocketTransport { + public partial class UnixDomainSocketMessageTransportListenerOptions + { + public bool Enabled { get { throw null; } set { } } + + public string Path { get { throw null; } set { } } + } + public static partial class UnixSocketConnectionExtensions { public static Hosting.IClientBuilder UseUnixSocketConnection(this Hosting.IClientBuilder clientBuilder) { throw null; } diff --git a/test/Benchmarks/Ping/ConcurrentLoadGenerator.cs b/test/Benchmarks/Ping/ConcurrentLoadGenerator.cs index 4eed06990bd..d5734c68f62 100644 --- a/test/Benchmarks/Ping/ConcurrentLoadGenerator.cs +++ b/test/Benchmarks/Ping/ConcurrentLoadGenerator.cs @@ -26,6 +26,7 @@ private struct WorkBlock private readonly int _numWorkers; private readonly int _blocksPerWorker; private readonly int _requestsPerBlock; + private readonly int _warmupBlocksPerWorker; public ConcurrentLoadGenerator( int maxConcurrency, @@ -33,7 +34,8 @@ public ConcurrentLoadGenerator( int requestsPerBlock, Func issueRequest, Func getStateForWorker, - bool logIntermediateResults = false) + bool logIntermediateResults = false, + int warmupBlocksPerWorker = 3) { this._numWorkers = maxConcurrency; this._blocksPerWorker = blocksPerWorker; @@ -41,6 +43,7 @@ public ConcurrentLoadGenerator( this._issueRequest = issueRequest; this._getStateForWorker = getStateForWorker; this._logIntermediateResults = logIntermediateResults; + this._warmupBlocksPerWorker = warmupBlocksPerWorker; this._tasks = new Task[maxConcurrency]; this._states = new TState[maxConcurrency]; } @@ -53,7 +56,7 @@ public async Task Warmup() for (var ree = 0; ree < this._numWorkers; ree++) { this._states[ree] = _getStateForWorker(ree); - this._tasks[ree] = this.RunWorker(this._states[ree], this._requestsPerBlock, 3); + this._tasks[ree] = this.RunWorker(this._states[ree], this._requestsPerBlock, this._warmupBlocksPerWorker); } // Wait for warmup to complete. diff --git a/test/Benchmarks/Ping/PingBenchmark.cs b/test/Benchmarks/Ping/PingBenchmark.cs index 92d3ce782d7..52c29940757 100644 --- a/test/Benchmarks/Ping/PingBenchmark.cs +++ b/test/Benchmarks/Ping/PingBenchmark.cs @@ -98,24 +98,27 @@ public Task PingConcurrentForever() => this.Run( grainFactory: this.client!, blocksPerWorker: 10); - public Task PingConcurrent() => this.Run( - runs: 10, + public Task PingConcurrent(int runs = 10, int blocksPerWorker = 10, int warmupBlocksPerWorker = 3) => this.Run( + runs, grainFactory: this.client!, - blocksPerWorker: 10); + blocksPerWorker, + warmupBlocksPerWorker); - public Task PingConcurrentHostedClient(int blocksPerWorker = 30) => this.Run( - runs: 10, + public Task PingConcurrentHostedClient(int blocksPerWorker = 30, int runs = 10, int warmupBlocksPerWorker = 3) => this.Run( + runs, grainFactory: (IGrainFactory)this.hosts[0].Services.GetService(typeof(IGrainFactory))!, - blocksPerWorker: blocksPerWorker); + blocksPerWorker, + warmupBlocksPerWorker); - private async Task Run(int runs, IGrainFactory grainFactory, int blocksPerWorker) + private async Task Run(int runs, IGrainFactory grainFactory, int blocksPerWorker, int warmupBlocksPerWorker = 3) { var loadGenerator = new ConcurrentLoadGenerator( maxConcurrency: 100, blocksPerWorker: blocksPerWorker, requestsPerBlock: 500, issueRequest: g => g.Run(), - getStateForWorker: workerId => grainFactory.GetGrain(workerId)); + getStateForWorker: workerId => grainFactory.GetGrain(workerId), + warmupBlocksPerWorker: warmupBlocksPerWorker); await loadGenerator.Warmup(); while (runs-- > 0) await loadGenerator.Run(); } diff --git a/test/Benchmarks/Ping/ProcessPingBenchmark.cs b/test/Benchmarks/Ping/ProcessPingBenchmark.cs new file mode 100644 index 00000000000..b9974cebff5 --- /dev/null +++ b/test/Benchmarks/Ping/ProcessPingBenchmark.cs @@ -0,0 +1,369 @@ +using System.Diagnostics; +using System.Net; +using System.Runtime.InteropServices; +using System.Runtime; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using BenchmarkGrainInterfaces.Ping; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Orleans.Hosting; + +namespace Benchmarks.Ping; + +internal static class ProcessPingBenchmark +{ + public static async Task RunServerAsync(string[] args) + { + var duration = GetInt32(args, 0, 240); + var siloPort = GetInt32(args, 1, 11111); + var gatewayPort = GetInt32(args, 2, 30000); + var useTls = GetBoolean(args, 3); + var primarySiloPort = GetInt32(args, 4, 0); + var primarySiloEndpoint = primarySiloPort > 0 + ? new IPEndPoint(IPAddress.Loopback, primarySiloPort) + : null; + using var certificate = useTls ? CreateCertificate(includeServerAuthentication: true) : null; + using var host = new HostBuilder() + .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) + .UseOrleans((_, siloBuilder) => + { + siloBuilder.UseLocalhostClustering(siloPort, gatewayPort, primarySiloEndpoint); + if (certificate is not null) + { + siloBuilder.UseTls(certificate, options => + { + options.SslProtocols = SslProtocols.Tls12; + SetTlsMode(options, "ClientCertificateMode", "RequireCertificate"); + SetTlsMode(options, "RemoteCertificateMode", "AllowCertificate"); + options.AllowAnyRemoteCertificate(); + }); + } + }) + .Build(); + + await host.StartAsync(); + Console.WriteLine("PING_SERVER_READY"); + await Task.Delay(TimeSpan.FromSeconds(duration)); + await host.StopAsync(); + } + + public static async Task RunClientAsync(string[] args) + { + var warmupSeconds = GetInt32(args, 0, 60); + var measurementSeconds = GetInt32(args, 1, 120); + var concurrency = GetInt32(args, 2, 250); + var gatewayPort = GetInt32(args, 3, 30000); + var sampleCount = GetInt32(args, 4, 1); + var useTls = GetBoolean(args, 5); + var payloadSize = GetInt32(args, 6, 0); + var secondaryGatewayPort = GetInt32(args, 7, 0); + using var certificate = useTls ? CreateCertificate(includeServerAuthentication: false) : null; + + using var host = new HostBuilder() + .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) + .UseOrleansClient((_, clientBuilder) => + { + if (secondaryGatewayPort > 0) + { + clientBuilder.UseStaticClustering([ + new IPEndPoint(IPAddress.Loopback, gatewayPort), + new IPEndPoint(IPAddress.Loopback, secondaryGatewayPort)]); + } + else + { + clientBuilder.UseLocalhostClustering(gatewayPort); + } + if (useTls) + { + clientBuilder.UseTls(certificate!, options => + { + options.SslProtocols = SslProtocols.Tls12; + SetTlsMode(options, "ClientCertificateMode", "RequireCertificate"); + SetTlsMode(options, "RemoteCertificateMode", "RequireCertificate"); + options.AllowAnyRemoteCertificate(); + options.OnAuthenticateAsClient = (_, sslOptions) => sslOptions.TargetHost = "localhost"; + }); + } + }) + .Build(); + + await host.StartAsync(); + var client = host.Services.GetRequiredService(); + var counters = new PaddedCounter[concurrency]; + var failures = new PaddedCounter[concurrency]; + var workers = new Task[concurrency]; + var payload = new byte[payloadSize]; + using var cancellation = new CancellationTokenSource(); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + for (var i = 0; i < workers.Length; i++) + { + var workerId = i; + workers[i] = payload.Length == 0 + ? RunWorker( + workerId, + client.GetGrain(workerId), + start.Task, + counters, + failures, + cancellation.Token) + : RunPayloadWorker( + workerId, + client.GetGrain(workerId), + payload, + start.Task, + counters, + failures, + cancellation.Token); + } + + start.SetResult(); + await Task.Delay(TimeSpan.FromSeconds(warmupSeconds)); + Console.WriteLine( + $"PING_ENV processors={Environment.ProcessorCount} serverGC={GCSettings.IsServerGC} " + + $"latencyMode={GCSettings.LatencyMode}"); + + var throughputs = new double[sampleCount]; + long totalCompleted = 0; + long totalFailures = 0; + long totalAllocatedBytes = 0; + double totalElapsedSeconds = 0; + + for (var sample = 0; sample < sampleCount; sample++) + { + var initialCount = Sum(counters); + var initialFailures = Sum(failures); + var initialAllocatedBytes = GC.GetTotalAllocatedBytes(precise: false); + var initialGen0Collections = GC.CollectionCount(0); + var initialGen1Collections = GC.CollectionCount(1); + var initialGen2Collections = GC.CollectionCount(2); + var startTimestamp = Stopwatch.GetTimestamp(); + + await Task.Delay(TimeSpan.FromSeconds(measurementSeconds)); + + var endTimestamp = Stopwatch.GetTimestamp(); + var completed = Sum(counters) - initialCount; + var failed = Sum(failures) - initialFailures; + var allocatedBytes = GC.GetTotalAllocatedBytes(precise: false) - initialAllocatedBytes; + var elapsedSeconds = Stopwatch.GetElapsedTime(startTimestamp, endTimestamp).TotalSeconds; + var throughput = completed / elapsedSeconds; + throughputs[sample] = throughput; + totalCompleted += completed; + totalFailures += failed; + totalAllocatedBytes += allocatedBytes; + totalElapsedSeconds += elapsedSeconds; + + Console.WriteLine( + $"PING_SAMPLE index={sample} throughput={throughput:F2} completed={completed} " + + $"seconds={elapsedSeconds:F3} failures={failed} concurrency={concurrency} " + + $"payloadMBps={throughput * payloadSize / 1_000_000:F2} " + + $"allocatedBytes={allocatedBytes} bytesPerOperation={(double)allocatedBytes / completed:F2} " + + $"gen0={GC.CollectionCount(0) - initialGen0Collections} " + + $"gen1={GC.CollectionCount(1) - initialGen1Collections} " + + $"gen2={GC.CollectionCount(2) - initialGen2Collections}"); + } + + await cancellation.CancelAsync(); + await Task.WhenAll(workers); + + Array.Sort(throughputs); + var medianThroughput = throughputs.Length % 2 == 0 + ? (throughputs[(throughputs.Length / 2) - 1] + throughputs[throughputs.Length / 2]) / 2 + : throughputs[throughputs.Length / 2]; + + Console.WriteLine( + $"PING_RESULT throughput={medianThroughput:F2} completed={totalCompleted} " + + $"seconds={totalElapsedSeconds:F3} failures={totalFailures} concurrency={concurrency} " + + $"payloadMBps={medianThroughput * payloadSize / 1_000_000:F2} " + + $"allocatedBytes={totalAllocatedBytes} bytesPerOperation={(double)totalAllocatedBytes / totalCompleted:F2} " + + $"samples={sampleCount}"); + + await host.StopAsync(); + } + + public static async Task RunLatencyClientAsync(string[] args) + { + var warmupSeconds = GetInt32(args, 0, 60); + var measurementSeconds = GetInt32(args, 1, 20); + var sampleCount = GetInt32(args, 2, 3); + var gatewayPort = GetInt32(args, 3, 30000); + var useTls = GetBoolean(args, 4); + using var certificate = useTls ? CreateCertificate(includeServerAuthentication: false) : null; + + using var host = new HostBuilder() + .ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning)) + .UseOrleansClient((_, clientBuilder) => + { + clientBuilder.UseLocalhostClustering(gatewayPort); + if (useTls) + { + clientBuilder.UseTls(certificate!, options => + { + options.SslProtocols = SslProtocols.Tls12; + SetTlsMode(options, "ClientCertificateMode", "RequireCertificate"); + SetTlsMode(options, "RemoteCertificateMode", "RequireCertificate"); + options.AllowAnyRemoteCertificate(); + options.OnAuthenticateAsClient = (_, sslOptions) => sslOptions.TargetHost = "localhost"; + }); + } + }) + .Build(); + + await host.StartAsync(); + var client = host.Services.GetRequiredService(); + var grain = client.GetGrain(0); + + var warmupStart = Stopwatch.GetTimestamp(); + while (Stopwatch.GetElapsedTime(warmupStart).TotalSeconds < warmupSeconds) + { + await grain.Run(); + } + + Console.WriteLine( + $"PING_ENV processors={Environment.ProcessorCount} serverGC={GCSettings.IsServerGC} " + + $"latencyMode={GCSettings.LatencyMode}"); + + var durations = new long[2_000_000]; + for (var sample = 0; sample < sampleCount; sample++) + { + var count = 0; + var sampleStart = Stopwatch.GetTimestamp(); + while (count < durations.Length + && Stopwatch.GetElapsedTime(sampleStart).TotalSeconds < measurementSeconds) + { + var start = Stopwatch.GetTimestamp(); + await grain.Run(); + durations[count++] = Stopwatch.GetTimestamp() - start; + } + + var elapsedSeconds = Stopwatch.GetElapsedTime(sampleStart).TotalSeconds; + Array.Sort(durations, 0, count); + var tickToMicroseconds = 1_000_000d / Stopwatch.Frequency; + Console.WriteLine( + $"PING_LATENCY index={sample} count={count} throughput={count / elapsedSeconds:F2} " + + $"p50us={Percentile(durations, count, 0.50) * tickToMicroseconds:F2} " + + $"p95us={Percentile(durations, count, 0.95) * tickToMicroseconds:F2} " + + $"p99us={Percentile(durations, count, 0.99) * tickToMicroseconds:F2} " + + $"p100us={durations[count - 1] * tickToMicroseconds:F2}"); + } + + await host.StopAsync(); + } + + private static async Task RunWorker( + int workerId, + IPingGrain grain, + Task start, + PaddedCounter[] counters, + PaddedCounter[] failures, + CancellationToken cancellationToken) + { + await start.ConfigureAwait(false); + while (!cancellationToken.IsCancellationRequested) + { + try + { + await grain.Run().ConfigureAwait(false); + counters[workerId].Value++; + } + catch when (!cancellationToken.IsCancellationRequested) + { + failures[workerId].Value++; + } + } + } + + private static async Task RunPayloadWorker( + int workerId, + IPayloadGrain grain, + byte[] payload, + Task start, + PaddedCounter[] counters, + PaddedCounter[] failures, + CancellationToken cancellationToken) + { + await start.ConfigureAwait(false); + while (!cancellationToken.IsCancellationRequested) + { + try + { + await grain.Run(payload).ConfigureAwait(false); + counters[workerId].Value++; + } + catch when (!cancellationToken.IsCancellationRequested) + { + failures[workerId].Value++; + } + } + } + + private static long Sum(PaddedCounter[] counters) + { + long result = 0; + foreach (ref var counter in counters.AsSpan()) + { + result += Volatile.Read(ref counter.Value); + } + + return result; + } + + private static int GetInt32(string[] args, int index, int defaultValue) + => args.Length > index && int.TryParse(args[index], out var value) && value > 0 ? value : defaultValue; + + private static bool GetBoolean(string[] args, int index) + => args.Length > index && bool.TryParse(args[index], out var value) && value; + + private static void SetTlsMode(object options, string propertyName, string value) + { + var property = options.GetType().GetProperty(propertyName)!; + property.SetValue(options, Enum.Parse(property.PropertyType, value)); + } + + private static X509Certificate2 CreateCertificate(bool includeServerAuthentication) + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); + var enhancedKeyUsages = new OidCollection { new("1.3.6.1.5.5.7.3.2") }; + if (includeServerAuthentication) + { + enhancedKeyUsages.Add(new("1.3.6.1.5.5.7.3.1")); + } + + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(enhancedKeyUsages, false)); + var subjectAlternativeName = new SubjectAlternativeNameBuilder(); + subjectAlternativeName.AddDnsName("localhost"); + subjectAlternativeName.AddIpAddress(IPAddress.Loopback); + request.CertificateExtensions.Add(subjectAlternativeName.Build()); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); + var pfx = certificate.Export(X509ContentType.Pkcs12, "benchmark-only"); +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12( + pfx, + "benchmark-only", + X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.UserKeySet); +#else +#pragma warning disable SYSLIB0057 + return new X509Certificate2( + pfx, + "benchmark-only", + X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.UserKeySet); +#pragma warning restore SYSLIB0057 +#endif + } + + private static long Percentile(long[] values, int count, double percentile) + => values[Math.Min(count - 1, (int)Math.Ceiling(count * percentile) - 1)]; + + [StructLayout(LayoutKind.Explicit, Size = 128)] + private struct PaddedCounter + { + [FieldOffset(0)] + public long Value; + } +} diff --git a/test/Benchmarks/Ping/RunProcessPingBenchmark.ps1 b/test/Benchmarks/Ping/RunProcessPingBenchmark.ps1 new file mode 100644 index 00000000000..e2ad934c260 --- /dev/null +++ b/test/Benchmarks/Ping/RunProcessPingBenchmark.ps1 @@ -0,0 +1,273 @@ +param( + [Parameter(Mandatory = $true)] + [string] $BenchmarksDll, + [string] $DotnetExecutable = "dotnet", + [int] $WarmupSeconds = 60, + [int] $MeasurementSeconds = 120, + [int] $SampleCount = 1, + [int] $Concurrency = 250, + [int] $PayloadSize = 0, + [switch] $Latency, + [switch] $Tls, + [switch] $SecondarySilo, + [string] $OutputDirectory = ".", + [string] $PvanalyzeDll, + [string] $DotnetCounters, + [string] $CounterProviders = "System.Runtime", + [ValidateSet("cpu", "gc-verbose")] + [string] $PvanalyzeProfile = "cpu", + [ValidateSet("both", "client", "server")] + [string] $ProfileRole = "both" +) + +$ErrorActionPreference = "Stop" + +Add-Type -TypeDefinition @" +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +public static class ProcessorTopology +{ + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetLogicalProcessorInformationEx( + int relationshipType, + IntPtr buffer, + ref uint returnedLength); + + public static ulong[] GetPhysicalCoreMasks() + { + const int RelationProcessorCore = 0; + uint length = 0; + GetLogicalProcessorInformationEx(RelationProcessorCore, IntPtr.Zero, ref length); + var buffer = Marshal.AllocHGlobal(checked((int)length)); + try + { + if (!GetLogicalProcessorInformationEx(RelationProcessorCore, buffer, ref length)) + { + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + } + + var result = new List(); + var offset = 0; + while (offset < length) + { + var item = IntPtr.Add(buffer, offset); + var size = Marshal.ReadInt32(item, 4); + var groupCount = (ushort)Marshal.ReadInt16(item, 30); + for (var group = 0; group < groupCount; group++) + { + result.Add(unchecked((ulong)Marshal.ReadInt64(item, 32 + (group * 16)))); + } + + offset += size; + } + + return result.ToArray(); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } +} +"@ + +function Start-AffinitizedDotnet( + [string[]] $Arguments, + [UInt64] $Affinity, + [string] $StandardOutput, + [string] $StandardError) +{ + $quotedArguments = $Arguments | ForEach-Object { '"' + $_.Replace('"', '\"') + '"' } + $command = 'start "" /b /wait /high /affinity {0:X} "{1}" {2}' -f ` + $Affinity, $DotnetExecutable, ($quotedArguments -join " ") + return Start-Process $env:ComSpec -PassThru -NoNewWindow ` + -ArgumentList @("/d", "/s", "/c", $command) ` + -RedirectStandardOutput $StandardOutput ` + -RedirectStandardError $StandardError +} + +function Get-DotnetChild([System.Diagnostics.Process] $wrapper) +{ + $child = Get-CimInstance Win32_Process -Filter "ParentProcessId = $($wrapper.Id)" | + Where-Object Name -eq "dotnet.exe" | + Select-Object -First 1 + if (-not $child) + { + throw "Could not find the dotnet child process for wrapper $($wrapper.Id)." + } + + return Get-Process -Id $child.ProcessId +} + +$coreMasks = [ProcessorTopology]::GetPhysicalCoreMasks() +if ($coreMasks.Count -lt 16) +{ + throw "Expected at least 16 physical cores, found $($coreMasks.Count)." +} + +[UInt64] $serverMask = 0 +[UInt64] $secondaryServerMask = 0 +[UInt64] $clientMask = 0 +for ($i = 0; $i -lt $(if ($SecondarySilo) { 4 } else { 8 }); $i++) +{ + $serverMask = $serverMask -bor $coreMasks[$i] +} + +if ($SecondarySilo) +{ + for ($i = 4; $i -lt 8; $i++) + { + $secondaryServerMask = $secondaryServerMask -bor $coreMasks[$i] + } +} + +for ($i = 8; $i -lt 16; $i++) +{ + $clientMask = $clientMask -bor $coreMasks[$i] +} + +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +$serverOutput = Join-Path $OutputDirectory "server.log" +$serverError = Join-Path $OutputDirectory "server.err.log" +$secondaryServerOutput = Join-Path $OutputDirectory "secondary-server.log" +$secondaryServerError = Join-Path $OutputDirectory "secondary-server.err.log" +$clientOutput = Join-Path $OutputDirectory "client.log" +$clientError = Join-Path $OutputDirectory "client.err.log" + +$serverDuration = $WarmupSeconds + ($MeasurementSeconds * $SampleCount) + 60 +$server = Start-AffinitizedDotnet ` + @($BenchmarksDll, "ProcessPing_Server", $serverDuration, 11111, 30000, [bool]$Tls, 0) ` + $serverMask ` + $serverOutput ` + $serverError +$secondaryServer = $null +if ($SecondarySilo) +{ + $secondaryServer = Start-AffinitizedDotnet ` + @($BenchmarksDll, "ProcessPing_Server", $serverDuration, 11112, 30001, [bool]$Tls, 11111) ` + $secondaryServerMask ` + $secondaryServerOutput ` + $secondaryServerError +} + +try +{ + $deadline = [DateTime]::UtcNow.AddSeconds(60) + while ([DateTime]::UtcNow -lt $deadline) + { + if ($server.HasExited) + { + throw "Server exited before becoming ready. See $serverError." + } + + if ((Test-Path $serverOutput) -and (Select-String -Path $serverOutput -Pattern "PING_SERVER_READY" -Quiet)) + { + break + } + + Start-Sleep -Milliseconds 200 + } + + if (-not (Select-String -Path $serverOutput -Pattern "PING_SERVER_READY" -Quiet)) + { + throw "Timed out waiting for the server to become ready." + } + + if ($secondaryServer) + { + $deadline = [DateTime]::UtcNow.AddSeconds(60) + while ([DateTime]::UtcNow -lt $deadline) + { + if ($secondaryServer.HasExited) + { + throw "Secondary server exited before becoming ready. See $secondaryServerError." + } + + if ((Test-Path $secondaryServerOutput) -and (Select-String -Path $secondaryServerOutput -Pattern "PING_SERVER_READY" -Quiet)) + { + break + } + + Start-Sleep -Milliseconds 200 + } + + if (-not (Select-String -Path $secondaryServerOutput -Pattern "PING_SERVER_READY" -Quiet)) + { + throw "Timed out waiting for the secondary server to become ready." + } + } + + $clientArguments = if ($Latency) + { + @($BenchmarksDll, "ProcessPing_LatencyClient", $WarmupSeconds, $MeasurementSeconds, $SampleCount, 30000, [bool]$Tls) + } + else + { + @($BenchmarksDll, "ProcessPing_Client", $WarmupSeconds, $MeasurementSeconds, $Concurrency, 30000, $SampleCount, [bool]$Tls, $PayloadSize, $(if ($SecondarySilo) { 30001 } else { 0 })) + } + $client = Start-AffinitizedDotnet ` + $clientArguments ` + $clientMask ` + $clientOutput ` + $clientError + + if ($PvanalyzeDll -or $DotnetCounters) + { + Start-Sleep -Seconds $WarmupSeconds + $serverDotnet = Get-DotnetChild $server + $clientDotnet = Get-DotnetChild $client + } + + if ($PvanalyzeDll) + { + if ($ProfileRole -in @("both", "server")) + { + & dotnet $PvanalyzeDll collect --process-id $serverDotnet.Id --profile $PvanalyzeProfile --duration-seconds 30 ` + --output (Join-Path $OutputDirectory "server.nettrace") + } + + if ($ProfileRole -in @("both", "client")) + { + & dotnet $PvanalyzeDll collect --process-id $clientDotnet.Id --profile $PvanalyzeProfile --duration-seconds 30 ` + --output (Join-Path $OutputDirectory "client.nettrace") + } + } + + if ($DotnetCounters) + { + $counterDuration = [TimeSpan]::FromSeconds($MeasurementSeconds * $SampleCount) + & $DotnetCounters collect --process-id $clientDotnet.Id --counters $CounterProviders ` + --refresh-interval 1 --format csv ` + --duration $counterDuration.ToString("dd\:hh\:mm\:ss") ` + --output (Join-Path $OutputDirectory "client-counters") + } + + $client.WaitForExit() + + if ($client.ExitCode -ne 0) + { + throw "Client exited with code $($client.ExitCode). See $clientError." + } + + Get-Content $clientOutput | Select-String "PING_(ENV|SAMPLE|RESULT|LATENCY)" +} +finally +{ + if (-not $server.HasExited) + { + Get-CimInstance Win32_Process -Filter "ParentProcessId = $($server.Id)" | + ForEach-Object { Stop-Process -Id $_.ProcessId -ErrorAction SilentlyContinue } + Stop-Process -Id $server.Id + $server.WaitForExit() + } + + if ($secondaryServer -and -not $secondaryServer.HasExited) + { + Get-CimInstance Win32_Process -Filter "ParentProcessId = $($secondaryServer.Id)" | + ForEach-Object { Stop-Process -Id $_.ProcessId -ErrorAction SilentlyContinue } + Stop-Process -Id $secondaryServer.Id + $secondaryServer.WaitForExit() + } +} diff --git a/test/Benchmarks/Program.cs b/test/Benchmarks/Program.cs index 95c88111204..ba93d92aed0 100644 --- a/test/Benchmarks/Program.cs +++ b/test/Benchmarks/Program.cs @@ -142,6 +142,15 @@ internal class Program { new PingBenchmark(numSilos: 1, startClient: true).PingConcurrent().GetAwaiter().GetResult(); }, + ["StablePing_OneSilo"] = _ => + { + new PingBenchmark(numSilos: 1, startClient: true) + .PingConcurrent(runs: 1, blocksPerWorker: 200, warmupBlocksPerWorker: 50) + .GetAwaiter().GetResult(); + }, + ["ProcessPing_Server"] = args => ProcessPingBenchmark.RunServerAsync(args).GetAwaiter().GetResult(), + ["ProcessPing_Client"] = args => ProcessPingBenchmark.RunClientAsync(args).GetAwaiter().GetResult(), + ["ProcessPing_LatencyClient"] = args => ProcessPingBenchmark.RunLatencyClientAsync(args).GetAwaiter().GetResult(), ["ConcurrentPing_TwoSilos"] = _ => { new PingBenchmark(numSilos: 2, startClient: true).PingConcurrent().GetAwaiter().GetResult(); @@ -174,6 +183,12 @@ internal class Program { new PingBenchmark(numSilos: 2, startClient: false, grainsOnSecondariesOnly: true).PingConcurrentHostedClient(blocksPerWorker: 10).GetAwaiter().GetResult(); }, + ["StablePing_SiloToSilo"] = _ => + { + new PingBenchmark(numSilos: 2, startClient: false, grainsOnSecondariesOnly: true) + .PingConcurrentHostedClient(blocksPerWorker: 200, runs: 1, warmupBlocksPerWorker: 50) + .GetAwaiter().GetResult(); + }, ["ConcurrentPing_SiloToSilo_Forever"] = _ => { //Console.WriteLine("Press any key to begin."); diff --git a/test/Benchmarks/Serialization/ComplexTypeBenchmarks.cs b/test/Benchmarks/Serialization/ComplexTypeBenchmarks.cs index d53c130e44a..814de1e4d6a 100644 --- a/test/Benchmarks/Serialization/ComplexTypeBenchmarks.cs +++ b/test/Benchmarks/Serialization/ComplexTypeBenchmarks.cs @@ -5,7 +5,6 @@ using Benchmarks.Serialization.Utilities; using Microsoft.Extensions.DependencyInjection; using Orleans.Configuration; -using Orleans.Networking.Shared; using Orleans.Runtime.Messaging; using Orleans.Serialization; using Orleans.Serialization.Buffers; @@ -83,8 +82,7 @@ public ComplexTypeBenchmarks() _readBytesLength = _serializedPayload.Length; _pipe = new Pipe(new PipeOptions(readerScheduler: PipeScheduler.Inline, writerScheduler: PipeScheduler.Inline, pauseWriterThreshold: 0)); - var memoryPool = new SharedMemoryPool(); - _messageSerializer = new(_sessionPool, memoryPool, new SiloMessagingOptions()); + _messageSerializer = new(_sessionPool, new SiloMessagingOptions()); } [Fact] diff --git a/test/Grains/BenchmarkGrainInterfaces/Ping/IPayloadGrain.cs b/test/Grains/BenchmarkGrainInterfaces/Ping/IPayloadGrain.cs new file mode 100644 index 00000000000..7b36f7ac9c5 --- /dev/null +++ b/test/Grains/BenchmarkGrainInterfaces/Ping/IPayloadGrain.cs @@ -0,0 +1,6 @@ +namespace BenchmarkGrainInterfaces.Ping; + +public interface IPayloadGrain : IGrainWithIntegerKey +{ + ValueTask Run(byte[] payload); +} diff --git a/test/Grains/BenchmarkGrains/Ping/PayloadGrain.cs b/test/Grains/BenchmarkGrains/Ping/PayloadGrain.cs new file mode 100644 index 00000000000..367852cc22e --- /dev/null +++ b/test/Grains/BenchmarkGrains/Ping/PayloadGrain.cs @@ -0,0 +1,8 @@ +using BenchmarkGrainInterfaces.Ping; + +namespace BenchmarkGrains.Ping; + +public sealed class PayloadGrain : Grain, IPayloadGrain +{ + public ValueTask Run(byte[] payload) => default; +} diff --git a/test/Orleans.Connections.Security.Tests/CertificateCreator.cs b/test/Orleans.Connections.Security.Tests/CertificateCreator.cs index 599197c0813..bb4d2be8f1b 100644 --- a/test/Orleans.Connections.Security.Tests/CertificateCreator.cs +++ b/test/Orleans.Connections.Security.Tests/CertificateCreator.cs @@ -58,19 +58,6 @@ public static X509Certificate2 ConvertFromBase64(string encodedCertificate) #pragma warning disable SYSLIB0057 return new X509Certificate2(rawData, "testing-only"); #pragma warning restore SYSLIB0057 -#endif - } - - public static X509Certificate2 CreateCertificateWithoutPrivateKey(string subjectName) - { - using var certificate = CreateSelfSignedCertificate(subjectName); - var rawData = certificate.Export(X509ContentType.Cert); -#if NET10_0_OR_GREATER - return X509CertificateLoader.LoadCertificate(rawData); -#else -#pragma warning disable SYSLIB0057 - return new X509Certificate2(rawData); -#pragma warning restore SYSLIB0057 #endif } } diff --git a/test/Orleans.Connections.Security.Tests/ClientHostingExtensionsTests.cs b/test/Orleans.Connections.Security.Tests/ClientHostingExtensionsTests.cs deleted file mode 100644 index 28e89eff57a..00000000000 --- a/test/Orleans.Connections.Security.Tests/ClientHostingExtensionsTests.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Orleans.Configuration; -using Orleans.Connections.Security; -using Orleans.Hosting; -using Xunit; - -namespace Orleans.Connections.Security.Tests; - -public class ClientHostingExtensionsTests -{ - [Fact] - public void UseTls_NullCertificateWithConfigureAction_ThrowsArgumentNullExceptionForCertificate() - { - var builder = new RecordingClientBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls((X509Certificate2)null!, static _ => { })); - - Assert.Equal("certificate", exception.ParamName); - Assert.Equal(0, builder.ClientConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_NullCertificateWithoutConfigureAction_ThrowsArgumentNullExceptionForCertificate() - { - var builder = new RecordingClientBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls((X509Certificate2)null!)); - - Assert.Equal("certificate", exception.ParamName); - Assert.Equal(0, builder.ClientConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_NullConfigureAction_ThrowsArgumentNullExceptionForConfigureOptions() - { - var builder = new RecordingClientBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls((Action)null!)); - - Assert.Equal("configureOptions", exception.ParamName); - Assert.Equal(0, builder.ClientConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_CertificateOverloadWithNullConfigureAction_ThrowsArgumentNullExceptionForConfigureOptions() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("client-null-configure.test"); - var builder = new RecordingClientBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls(certificate, null!)); - - Assert.Equal("configureOptions", exception.ParamName); - Assert.Equal(0, builder.ClientConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_StoreOverloadWithNullConfigureAction_ThrowsBeforeAccessingCertificateStore() - { - var builder = new RecordingClientBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls( - StoreName.My, - "unused.test", - allowInvalid: false, - StoreLocation.CurrentUser, - null!)); - - Assert.Equal("configureOptions", exception.ParamName); - Assert.Equal(0, builder.ClientConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_WithoutLocalCertificate_WhenClientCertificateIsRequired_ThrowsConfigurationFailure() - { - var builder = new RecordingClientBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls(options => - options.ClientCertificateMode = RemoteCertificateMode.RequireCertificate)); - - Assert.Equal("No certificate specified", exception.Message); - Assert.Equal(0, builder.ClientConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_WithoutLocalCertificate_DefaultClientCertificateMode_RegistersOutboundTls() - { - var builder = new RecordingClientBuilder(); - - var result = builder.UseTls(static _ => { }); - - Assert.Same(builder, result); - Assert.Equal(1, builder.ClientConnectionOptionsConfigurationCount); - using var provider = builder.BuildServiceProvider(); - var connectionOptions = provider.GetRequiredService>().Value; - var connectionBuilder = new RecordingConnectionBuilder(provider); - connectionOptions.ApplyTo(connectionBuilder); - Assert.Equal(1, connectionBuilder.MiddlewareRegistrationCount); - } - - [Fact] - public void UseTls_CertificateWithoutPrivateKey_ThrowsArgumentExceptionForCertificate() - { - using var certificate = TestCertificateHelper.CreateCertificateWithoutPrivateKey("client-no-private-key.test"); - var builder = new RecordingClientBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls(certificate, static _ => { })); - - Assert.Equal("certificate", exception.ParamName); - Assert.Contains("does not contain a private key", exception.Message); - Assert.Equal(0, builder.ClientConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_ConfiguredCertificateWithoutPrivateKey_ThrowsArgumentExceptionForTlsOptionsLocalCertificate() - { - using var certificate = TestCertificateHelper.CreateCertificateWithoutPrivateKey("configured-no-private-key.test"); - var builder = new RecordingClientBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls(options => options.LocalCertificate = certificate)); - - Assert.Equal("TlsOptions.LocalCertificate", exception.ParamName); - Assert.Contains("does not contain a private key", exception.Message); - Assert.Equal(0, builder.ClientConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_CertificateOverload_AssignsCertificateBeforeInvokingConfigureAction() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("client-order.test"); - var builder = new RecordingClientBuilder(); - X509Certificate2? certificateObservedByConfigureAction = null; - - var result = builder.UseTls( - certificate, - options => certificateObservedByConfigureAction = options.LocalCertificate); - - Assert.Same(builder, result); - Assert.Same(certificate, certificateObservedByConfigureAction); - Assert.Equal(1, builder.ClientConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_ConfigureActionMutations_AppearExactlyOnCapturedTlsOptions() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("client-options.test"); - var builder = new RecordingClientBuilder(); - var expectedTimeout = TimeSpan.FromMilliseconds(1_873); - RemoteCertificateValidator expectedValidator = static (_, _, errors) => - errors == System.Net.Security.SslPolicyErrors.RemoteCertificateNameMismatch; - TlsOptions? configuredOptions = null; - - builder.UseTls( - certificate, - options => - { - configuredOptions = options; - options.HandshakeTimeout = expectedTimeout; - options.RemoteCertificateMode = RemoteCertificateMode.AllowCertificate; - options.SslProtocols = SslProtocols.Tls13; - options.RemoteCertificateValidation = expectedValidator; - }); - - var actual = Assert.IsType(configuredOptions); - Assert.Same(certificate, actual.LocalCertificate); - Assert.Equal(expectedTimeout, actual.HandshakeTimeout); - Assert.Equal(RemoteCertificateMode.AllowCertificate, actual.RemoteCertificateMode); - Assert.Equal(SslProtocols.Tls13, actual.SslProtocols); - Assert.Same(expectedValidator, actual.RemoteCertificateValidation); - using var provider = builder.BuildServiceProvider(); - var connectionOptions = provider.GetRequiredService>().Value; - var connectionBuilder = new RecordingConnectionBuilder(provider); - connectionOptions.ApplyTo(connectionBuilder); - Assert.Equal(1, connectionBuilder.MiddlewareRegistrationCount); - } - - [Fact] - public void UseTls_RegistersExactlyOneOutboundClientTlsConnectionCallback() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("client-registration.test"); - var builder = new RecordingClientBuilder(); - - builder.UseTls(certificate); - - Assert.Equal(1, builder.ClientConnectionOptionsConfigurationCount); - using var provider = builder.BuildServiceProvider(); - var connectionOptions = provider.GetRequiredService>().Value; - var connectionBuilder = new RecordingConnectionBuilder(provider); - connectionOptions.ApplyTo(connectionBuilder); - Assert.Equal(1, connectionBuilder.MiddlewareRegistrationCount); - } - - [Fact] - public void UseTls_AppendsOutboundTlsWithoutReplacingExistingClientConnectionConfiguration() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("client-composition.test"); - var builder = new RecordingClientBuilder(); - var callOrder = new List(); - var existingCallbackInvocations = 0; - builder.Configure( - options => options.ConfigureConnection( - _ => - { - existingCallbackInvocations++; - callOrder.Add("existing"); - })); - - builder.UseTls(certificate); - - Assert.Equal(2, builder.ClientConnectionOptionsConfigurationCount); - using var provider = builder.BuildServiceProvider(); - var connectionOptions = provider.GetRequiredService>().Value; - var connectionBuilder = new RecordingConnectionBuilder(provider, callOrder); - connectionOptions.ApplyTo(connectionBuilder); - Assert.Equal(1, existingCallbackInvocations); - Assert.Equal(1, connectionBuilder.MiddlewareRegistrationCount); - Assert.Equal(["existing", "tls"], callOrder); - } -} diff --git a/test/Orleans.Connections.Security.Tests/DuplexPipeStreamAdapterTests.cs b/test/Orleans.Connections.Security.Tests/DuplexPipeStreamAdapterTests.cs deleted file mode 100644 index 02173863be9..00000000000 --- a/test/Orleans.Connections.Security.Tests/DuplexPipeStreamAdapterTests.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System.Buffers; -using System.IO.Pipelines; -using System.Net.Security; -using Orleans.Connections.Security; -using Xunit; - -namespace Orleans.Connections.Security.Tests; - -public class DuplexPipeStreamAdapterTests -{ - [Fact] - public async Task Adapter_DefaultConstructor_PassesItselfToFactoryAndWiresProperties() - { - var inbound = new Pipe(); - var outbound = new Pipe(); - var transport = new TestDuplexPipe(inbound.Reader, outbound.Writer); - Stream? factoryInput = null; - TrackingStream? decorated = null; - - var adapter = new DuplexPipeStreamAdapter( - transport, - stream => - { - factoryInput = stream; - return decorated = new TrackingStream(stream); - }); - - Assert.Same(adapter, factoryInput); - var tracked = Assert.IsType(decorated); - Assert.Same(tracked, adapter.Stream); - Assert.NotNull(adapter.Input); - Assert.NotNull(adapter.Output); - - await inbound.Writer.WriteAsync(new byte[] { 1, 2, 3 }, TestContext.Current.CancellationToken); - var read = await adapter.Input.ReadAsync(TestContext.Current.CancellationToken); - Assert.Equal(new byte[] { 1, 2, 3 }, read.Buffer.ToArray()); - adapter.Input.AdvanceTo(read.Buffer.End); - - await adapter.Output.WriteAsync(new byte[] { 4, 5, 6, 7 }, TestContext.Current.CancellationToken); - var written = await outbound.Reader.ReadAsync(TestContext.Current.CancellationToken); - Assert.Equal(new byte[] { 4, 5, 6, 7 }, written.Buffer.ToArray()); - outbound.Reader.AdvanceTo(written.Buffer.End); - - adapter.Dispose(); - Assert.Equal(0, tracked.DisposeCount); - Assert.Equal(0, tracked.DisposeAsyncCount); - } - - [Fact] - public async Task Adapter_ExplicitOptions_ApplyReaderWriterBehaviorAndWireProperties() - { - var inbound = new Pipe(); - var outbound = new Pipe(); - var transport = new TestDuplexPipe(inbound.Reader, outbound.Writer); - var readerOptions = new StreamPipeReaderOptions( - bufferSize: 64, - minimumReadSize: 16, - leaveOpen: false); - var writerOptions = new StreamPipeWriterOptions( - minimumBufferSize: 32, - leaveOpen: false); - TrackingStream? decorated = null; - - var adapter = new DuplexPipeStreamAdapter( - transport, - readerOptions, - writerOptions, - stream => decorated = new TrackingStream(stream)); - - var tracked = Assert.IsType(decorated); - Assert.Same(tracked, adapter.Stream); - Assert.NotNull(adapter.Input); - Assert.NotNull(adapter.Output); - - await inbound.Writer.WriteAsync(new byte[] { 11, 12 }, TestContext.Current.CancellationToken); - var read = await adapter.Input.ReadAsync(TestContext.Current.CancellationToken); - Assert.Equal(new byte[] { 11, 12 }, read.Buffer.ToArray()); - adapter.Input.AdvanceTo(read.Buffer.End); - - await adapter.DisposeAsync(); - - Assert.NotEqual(0, tracked.DisposeCount + tracked.DisposeAsyncCount); - } - - [Fact] - public async Task Adapter_MixedSyncAsyncDisposal_IsIdempotent() - { - var transport = new TestDuplexPipe(new Pipe().Reader, new Pipe().Writer); - TrackingStream? decorated = null; - var adapter = new DuplexPipeStreamAdapter( - transport, - new StreamPipeReaderOptions(leaveOpen: false), - new StreamPipeWriterOptions(leaveOpen: false), - stream => decorated = new TrackingStream(stream)); - - adapter.Dispose(); - var tracked = Assert.IsType(decorated); - var disposeCount = tracked.DisposeCount; - var disposeAsyncCount = tracked.DisposeAsyncCount; - await adapter.DisposeAsync(); - adapter.Dispose(); - - Assert.NotEqual(0, disposeCount + disposeAsyncCount); - Assert.Equal(disposeCount, tracked.DisposeCount); - Assert.Equal(disposeAsyncCount, tracked.DisposeAsyncCount); - } - - [Fact] - public void TlsDuplexPipe_DefaultFactory_CreatesSslStreamAndWiresPipe() - { - var transport = new TestDuplexPipe(new Pipe().Reader, new Pipe().Writer); - - using var adapter = new TlsDuplexPipe( - transport, - new StreamPipeReaderOptions(leaveOpen: true), - new StreamPipeWriterOptions(leaveOpen: true)); - - Assert.IsType(adapter.Stream); - Assert.NotNull(adapter.Input); - Assert.NotNull(adapter.Output); - Assert.NotSame(transport.Input, adapter.Input); - Assert.NotSame(transport.Output, adapter.Output); - Assert.False(adapter.Stream.IsAuthenticated); - Assert.False(adapter.Stream.IsEncrypted); - adapter.Stream.Dispose(); - } - - [Fact] - public void TlsDuplexPipe_CustomFactory_ReceivesAdapterAndPreservesExactStream() - { - var transport = new TestDuplexPipe(new Pipe().Reader, new Pipe().Writer); - Stream? factoryInput = null; - SslStream? sslStream = null; - - using var adapter = new TlsDuplexPipe( - transport, - new StreamPipeReaderOptions(leaveOpen: true), - new StreamPipeWriterOptions(leaveOpen: true), - stream => - { - factoryInput = stream; - return sslStream = new SslStream(stream, leaveInnerStreamOpen: true); - }); - - Assert.Same(adapter, factoryInput); - Assert.Same(sslStream, adapter.Stream); - Assert.NotNull(adapter.Input); - Assert.NotNull(adapter.Output); - adapter.Stream.Dispose(); - } - - [Fact] - public async Task TlsDuplexPipe_MixedSyncAsyncDisposal_IsIdempotent() - { - var transport = new TestDuplexPipe(new Pipe().Reader, new Pipe().Writer); - TrackingStream? tracked = null; - var adapter = new TlsDuplexPipe( - transport, - new StreamPipeReaderOptions(leaveOpen: false), - new StreamPipeWriterOptions(leaveOpen: false), - stream => - { - tracked = new TrackingStream(stream); - return new SslStream(tracked, leaveInnerStreamOpen: false); - }); - - await adapter.DisposeAsync(); - var trackingStream = Assert.IsType(tracked); - var disposeCount = trackingStream.DisposeCount; - var disposeAsyncCount = trackingStream.DisposeAsyncCount; - adapter.Dispose(); - await adapter.DisposeAsync(); - - Assert.NotEqual(0, disposeCount + disposeAsyncCount); - Assert.Equal(disposeCount, trackingStream.DisposeCount); - Assert.Equal(disposeAsyncCount, trackingStream.DisposeAsyncCount); - } - - [Fact] - public async Task Adapter_ReaderLeaveOpenFalse_DisposesDecoratedStream() - { - var transport = new TestDuplexPipe(new Pipe().Reader, new Pipe().Writer); - TrackingStream? decorated = null; - var adapter = new DuplexPipeStreamAdapter( - transport, - new StreamPipeReaderOptions(leaveOpen: false), - new StreamPipeWriterOptions(leaveOpen: true), - stream => decorated = new TrackingStream(stream)); - - await adapter.DisposeAsync(); - - var tracked = Assert.IsType(decorated); - Assert.Equal(1, tracked.DisposeCount + tracked.DisposeAsyncCount); - } - - [Fact] - public async Task Adapter_WriterLeaveOpenFalse_DisposesDecoratedStream() - { - var transport = new TestDuplexPipe(new Pipe().Reader, new Pipe().Writer); - TrackingStream? decorated = null; - var adapter = new DuplexPipeStreamAdapter( - transport, - new StreamPipeReaderOptions(leaveOpen: true), - new StreamPipeWriterOptions(leaveOpen: false), - stream => decorated = new TrackingStream(stream)); - - await adapter.DisposeAsync(); - - var tracked = Assert.IsType(decorated); - Assert.Equal(1, tracked.DisposeCount + tracked.DisposeAsyncCount); - } -} diff --git a/test/Orleans.Connections.Security.Tests/DuplexPipeStreamTests.cs b/test/Orleans.Connections.Security.Tests/DuplexPipeStreamTests.cs deleted file mode 100644 index 4051391c231..00000000000 --- a/test/Orleans.Connections.Security.Tests/DuplexPipeStreamTests.cs +++ /dev/null @@ -1,384 +0,0 @@ -using System.Buffers; -using System.IO.Pipelines; -using Orleans.Connections.Security; -using Xunit; - -namespace Orleans.Connections.Security.Tests; - -public class DuplexPipeStreamTests -{ - [Fact] - public void Properties_ReportReadableWritableNonSeekable() - { - var stream = CreateStream(); - - Assert.True(stream.CanRead); - Assert.True(stream.CanWrite); - Assert.False(stream.CanSeek); - } - - [Fact] - public async Task Read_ImmediatePartialData_ReturnsExactPrefixAndPreservesRemainder() - { - var inbound = new Pipe(); - await inbound.Writer.WriteAsync(new byte[] { 10, 20, 30, 40, 50 }, TestContext.Current.CancellationToken); - var stream = CreateStream(input: inbound.Reader); - var first = new byte[3]; - var second = new byte[4]; - - var firstCount = stream.Read(first, 0, first.Length); - var secondCount = stream.Read(second, 1, 2); - - Assert.Equal(3, firstCount); - Assert.Equal(new byte[] { 10, 20, 30 }, first); - Assert.Equal(2, secondCount); - Assert.Equal(new byte[] { 0, 40, 50, 0 }, second); - } - - [Fact] - public async Task Read_DelayedData_ReturnsExactBytes() - { - var reader = new ControlledPipeReader(); - var stream = CreateStream(input: reader); - var buffer = new byte[5]; - - var readTask = Task.Run(() => stream.Read(buffer, 1, 3)); - await reader.ReadStarted.WaitAsync(TestContext.Current.CancellationToken); - var sequence = new ReadOnlySequence(new byte[] { 7, 8, 9 }); - reader.SetResult(new(sequence, isCanceled: false, isCompleted: false)); - - Assert.Equal(3, await readTask); - Assert.Equal(new byte[] { 0, 7, 8, 9, 0 }, buffer); - Assert.Equal(sequence.End, reader.LastConsumed); - Assert.Equal(1, reader.AdvanceCount); - } - - [Fact] - public async Task ReadAsync_ImmediateMultiSegmentData_ConsumesExactlyReturnedBytes() - { - var sequence = SequenceFactory.Create([1, 2], [3, 4, 5], [6, 7]); - var reader = new ScriptedPipeReader( - _ => new(new ReadResult(sequence, isCanceled: false, isCompleted: false))); - var stream = CreateStream(input: reader); - var buffer = new byte[6]; - - var count = await stream.ReadAsync(buffer.AsMemory(1, 4), TestContext.Current.CancellationToken); - - Assert.Equal(4, count); - Assert.Equal(new byte[] { 0, 1, 2, 3, 4, 0 }, buffer); - Assert.Equal(sequence.GetPosition(4), reader.LastConsumed); - Assert.Equal(sequence.GetPosition(4), reader.LastExamined); - Assert.Equal(1, reader.AdvanceCount); - } - - [Fact] - public async Task ReadAsync_DelayedData_ReturnsExactBytes() - { - var reader = new ControlledPipeReader(); - var stream = CreateStream(input: reader); - var buffer = new byte[4]; - - var read = stream.ReadAsync(buffer.AsMemory(1, 2), TestContext.Current.CancellationToken); - await reader.ReadStarted.WaitAsync(TestContext.Current.CancellationToken); - var sequence = new ReadOnlySequence(new byte[] { 91, 92 }); - reader.SetResult(new(sequence, isCanceled: false, isCompleted: false)); - - Assert.Equal(2, await read); - Assert.Equal(new byte[] { 0, 91, 92, 0 }, buffer); - Assert.Equal(sequence.End, reader.LastConsumed); - Assert.Equal(TestContext.Current.CancellationToken, reader.LastReadCancellationToken); - } - - [Fact] - public async Task ReadAsync_CompletedEmptyReader_ReturnsEof() - { - var sequence = ReadOnlySequence.Empty; - var reader = new ScriptedPipeReader( - _ => new(new ReadResult(sequence, isCanceled: false, isCompleted: true))); - var stream = CreateStream(input: reader); - - var count = await stream.ReadAsync(new byte[4], TestContext.Current.CancellationToken); - - Assert.Equal(0, count); - Assert.Equal(sequence.Start, reader.LastConsumed); - Assert.Equal(1, reader.AdvanceCount); - } - - [Fact] - public async Task ReadAsync_CanceledResult_ThrowsOperationCanceledException() - { - var sequence = new ReadOnlySequence(new byte[] { 1, 2 }); - var reader = new ScriptedPipeReader( - _ => new(new ReadResult(sequence, isCanceled: true, isCompleted: false))); - var stream = CreateStream(input: reader); - - await Assert.ThrowsAsync( - () => stream.ReadAsync(new byte[4], TestContext.Current.CancellationToken).AsTask()); - - Assert.Equal(0, reader.AdvanceCount); - } - - [Fact] - public async Task ReadAsync_EmptyNonCompletedResult_ThrowsInvalidOperationException() - { - var sequence = ReadOnlySequence.Empty; - var reader = new ScriptedPipeReader( - _ => new(new ReadResult(sequence, isCanceled: false, isCompleted: false))); - var stream = CreateStream(input: reader); - - var exception = await Assert.ThrowsAsync( - () => stream.ReadAsync(new byte[4], TestContext.Current.CancellationToken).AsTask()); - - Assert.Equal("Read zero bytes unexpectedly", exception.Message); - Assert.Equal(sequence.Start, reader.LastConsumed); - Assert.Equal(1, reader.AdvanceCount); - } - - [Fact] - public void Read_InvalidByteArrayArguments_Throw() - { - var stream = CreateStream(); - var buffer = new byte[4]; - - Assert.Equal("buffer", Assert.Throws(() => stream.Read(null!, 0, 0)).ParamName); - Assert.Equal("offset", Assert.Throws(() => stream.Read(buffer, -1, 1)).ParamName); - Assert.Equal("count", Assert.Throws(() => stream.Read(buffer, 5, 0)).ParamName); - Assert.Equal("count", Assert.Throws(() => stream.Read(buffer, 0, -1)).ParamName); - Assert.Equal("count", Assert.Throws(() => stream.Read(buffer, 3, 2)).ParamName); - } - - [Fact] - public async Task ReadAsync_InvalidByteArrayArguments_Throw() - { - var stream = CreateStream(); - var buffer = new byte[4]; - - Assert.Equal("buffer", (await Assert.ThrowsAsync( - () => stream.ReadAsync(null!, 0, 0, TestContext.Current.CancellationToken))).ParamName); - Assert.Equal("offset", (await Assert.ThrowsAsync( - () => stream.ReadAsync(buffer, -1, 1, TestContext.Current.CancellationToken))).ParamName); - Assert.Equal("count", (await Assert.ThrowsAsync( - () => stream.ReadAsync(buffer, 5, 0, TestContext.Current.CancellationToken))).ParamName); - Assert.Equal("count", (await Assert.ThrowsAsync( - () => stream.ReadAsync(buffer, 0, -1, TestContext.Current.CancellationToken))).ParamName); - Assert.Equal("count", (await Assert.ThrowsAsync( - () => stream.ReadAsync(buffer, 3, 2, TestContext.Current.CancellationToken))).ParamName); - } - - [Fact] - public void Write_InvalidByteArrayArguments_Throw() - { - var stream = CreateStream(); - var buffer = new byte[4]; - - Assert.Equal("buffer", Assert.Throws(() => stream.Write(null!, 0, 0)).ParamName); - Assert.Equal("offset", Assert.Throws(() => stream.Write(buffer, -1, 1)).ParamName); - Assert.Equal("count", Assert.Throws(() => stream.Write(buffer, 5, 0)).ParamName); - Assert.Equal("count", Assert.Throws(() => stream.Write(buffer, 0, -1)).ParamName); - Assert.Equal("count", Assert.Throws(() => stream.Write(buffer, 3, 2)).ParamName); - } - - [Fact] - public async Task WriteAsync_InvalidByteArrayArguments_Throw() - { - var stream = CreateStream(); - var buffer = new byte[4]; - - Assert.Equal("buffer", (await Assert.ThrowsAsync( - () => stream.WriteAsync(null!, 0, 0, TestContext.Current.CancellationToken))).ParamName); - Assert.Equal("offset", (await Assert.ThrowsAsync( - () => stream.WriteAsync(buffer, -1, 1, TestContext.Current.CancellationToken))).ParamName); - Assert.Equal("count", (await Assert.ThrowsAsync( - () => stream.WriteAsync(buffer, 5, 0, TestContext.Current.CancellationToken))).ParamName); - Assert.Equal("count", (await Assert.ThrowsAsync( - () => stream.WriteAsync(buffer, 0, -1, TestContext.Current.CancellationToken))).ParamName); - Assert.Equal("count", (await Assert.ThrowsAsync( - () => stream.WriteAsync(buffer, 3, 2, TestContext.Current.CancellationToken))).ParamName); - } - - [Fact] - public void Write_WritesExactSelectedBytes() - { - var writer = new RecordingPipeWriter(new FlushResult(isCanceled: false, isCompleted: false)); - var stream = CreateStream(output: writer); - - stream.Write(new byte[] { 1, 2, 3, 4, 5 }, 1, 3); - - Assert.Equal(new byte[] { 2, 3, 4 }, writer.Written.ToArray()); - Assert.Equal(1, writer.FlushCount); - } - - [Fact] - public async Task WriteAsync_WritesExactSelectedBytes() - { - var writer = new RecordingPipeWriter(new FlushResult(isCanceled: false, isCompleted: false)); - var stream = CreateStream(output: writer); - - await stream.WriteAsync(new byte[] { 10, 20, 30, 40 }, 1, 2, TestContext.Current.CancellationToken); - - Assert.Equal(new byte[] { 20, 30 }, writer.Written.ToArray()); - Assert.Equal(1, writer.FlushCount); - Assert.Equal(TestContext.Current.CancellationToken, writer.LastFlushCancellationToken); - } - - [Fact] - public async Task WriteAsync_CanceledFlushResult_ThrowsOperationCanceledException() - { - var writer = new RecordingPipeWriter(new FlushResult(isCanceled: true, isCompleted: false)); - var stream = CreateStream(output: writer); - using var cancellation = new CancellationTokenSource(); - - var exception = await Assert.ThrowsAsync( - () => stream.WriteAsync(new byte[] { 4, 5 }, cancellation.Token).AsTask()); - - Assert.Equal(cancellation.Token, exception.CancellationToken); - Assert.Equal(new byte[] { 4, 5 }, writer.Written.ToArray()); - Assert.Equal(1, writer.FlushCount); - } - - [Fact] - public async Task FlushAndFlushAsync_CanceledResult_ThrowOperationCanceledException() - { - var writer = new RecordingPipeWriter(new FlushResult(isCanceled: true, isCompleted: false)); - var stream = CreateStream(output: writer); - using var cancellation = new CancellationTokenSource(); - - var synchronous = Assert.Throws(stream.Flush); - var asynchronous = await Assert.ThrowsAsync( - () => stream.FlushAsync(cancellation.Token)); - - Assert.Equal(CancellationToken.None, synchronous.CancellationToken); - Assert.Equal(cancellation.Token, asynchronous.CancellationToken); - Assert.Equal(2, writer.FlushCount); - Assert.Equal(cancellation.Token, writer.LastFlushCancellationToken); - } - - [Fact] - public async Task CopyToAsync_CopiesExactBytes() - { - var inbound = new Pipe(); - await inbound.Writer.WriteAsync(new byte[] { 1, 3, 5, 7, 9 }, TestContext.Current.CancellationToken); - await inbound.Writer.CompleteAsync(); - var stream = CreateStream(input: inbound.Reader); - await using var destination = new MemoryStream(); - - await stream.CopyToAsync(destination, 2, CancellationToken.None); - - Assert.Equal(new byte[] { 1, 3, 5, 7, 9 }, destination.ToArray()); - Assert.Equal(5, destination.Length); - } - - [Fact] - public async Task CopyToAsync_ForwardsCancellationTokenToEveryRead() - { - using var cancellation = new CancellationTokenSource(); - var invocation = 0; - var reader = new ScriptedPipeReader( - _ => - { - invocation++; - return invocation == 1 - ? new(new ReadResult(new ReadOnlySequence(new byte[] { 2, 4, 6 }), false, false)) - : new(new ReadResult(ReadOnlySequence.Empty, false, true)); - }); - var stream = CreateStream(input: reader); - await using var destination = new MemoryStream(); - - await stream.CopyToAsync(destination, 2, cancellation.Token); - - Assert.Equal(new byte[] { 2, 4, 6 }, destination.ToArray()); - Assert.Equal(2, reader.ReadCancellationTokens.Count); - Assert.All(reader.ReadCancellationTokens, token => Assert.Equal(cancellation.Token, token)); - } - - [Fact] - public async Task BeginReadAndEndRead_PreserveStateCallbackAndExactBytes() - { - var reader = new ControlledPipeReader(); - var stream = CreateStream(input: reader); - var buffer = new byte[4]; - var state = new object(); - var callback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - var result = stream.BeginRead(buffer, 1, 2, callback.SetResult, state); - await reader.ReadStarted.WaitAsync(TestContext.Current.CancellationToken); - var sequence = new ReadOnlySequence(new byte[] { 8, 9 }); - reader.SetResult(new(sequence, false, false)); - var callbackResult = await callback.Task.WaitAsync(TestContext.Current.CancellationToken); - - Assert.Same(result, callbackResult); - Assert.Same(state, result.AsyncState); - Assert.Equal(2, stream.EndRead(result)); - Assert.Equal(new byte[] { 0, 8, 9, 0 }, buffer); - } - - [Fact] - public async Task BeginWriteAndEndWrite_PreserveStateCallbackAndExactBytes() - { - var writer = new RecordingPipeWriter(new FlushResult(false, false)); - var stream = CreateStream(output: writer); - var state = new object(); - var callback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - var result = stream.BeginWrite(new byte[] { 1, 3, 5, 7 }, 1, 2, callback.SetResult, state); - var callbackResult = await callback.Task.WaitAsync(TestContext.Current.CancellationToken); - - Assert.Same(result, callbackResult); - Assert.Same(state, result.AsyncState); - stream.EndWrite(result); - Assert.Equal(new byte[] { 3, 5 }, writer.Written.ToArray()); - Assert.Equal(1, writer.FlushCount); - } - - [Fact] - public void UnsupportedSeekLengthPositionAndSetLength_Throw() - { - var stream = CreateStream(); - - Assert.Throws(() => stream.Seek(1, SeekOrigin.Begin)); - Assert.Throws(() => stream.SetLength(8)); - Assert.Throws(() => stream.Length); - Assert.Throws(() => stream.Position); - Assert.Throws(() => stream.Position = 3); - Assert.False(stream.CanSeek); - } - - [Fact] - public void Dispose_CompletesReaderAndWriterSynchronously() - { - var reader = new ScriptedPipeReader( - _ => new(new ReadResult(ReadOnlySequence.Empty, false, true))); - var writer = new RecordingPipeWriter(new FlushResult(false, false)); - var stream = CreateStream(reader, writer); - - stream.Dispose(); - - Assert.Equal(1, reader.CompleteCount); - Assert.Equal(0, reader.CompleteAsyncCount); - Assert.Equal(1, writer.CompleteCount); - Assert.Equal(0, writer.CompleteAsyncCount); - } - - [Fact] - public async Task DisposeAsync_CompletesReaderAndWriterAsynchronously() - { - var reader = new ScriptedPipeReader( - _ => new(new ReadResult(ReadOnlySequence.Empty, false, true))); - var writer = new RecordingPipeWriter(new FlushResult(false, false)); - var stream = CreateStream(reader, writer); - - await stream.DisposeAsync(); - - Assert.Equal(0, reader.CompleteCount); - Assert.Equal(1, reader.CompleteAsyncCount); - Assert.Equal(0, writer.CompleteCount); - Assert.Equal(1, writer.CompleteAsyncCount); - } - - private static DuplexPipeStream CreateStream(PipeReader? input = null, PipeWriter? output = null) - { - input ??= new Pipe().Reader; - output ??= new Pipe().Writer; - return new(new TestDuplexPipe(input, output)); - } -} diff --git a/test/Orleans.Connections.Security.Tests/HostingTestInfrastructure.cs b/test/Orleans.Connections.Security.Tests/HostingTestInfrastructure.cs deleted file mode 100644 index 4dd8d6a1979..00000000000 --- a/test/Orleans.Connections.Security.Tests/HostingTestInfrastructure.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.Runtime.CompilerServices; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Orleans.Configuration; -using Orleans.Hosting; - -namespace Orleans.Connections.Security.Tests; - -internal sealed class RecordingClientBuilder : IClientBuilder -{ - public RecordingClientBuilder() - { - Services = new ServiceCollection(); - Configuration = new ConfigurationManager(); - } - - public IServiceCollection Services { get; } - - public IConfiguration Configuration { get; } - - public int ClientConnectionOptionsConfigurationCount => - Services.Count(service => service.ServiceType == typeof(IConfigureOptions)); - - public ServiceProvider BuildServiceProvider() => Services.BuildServiceProvider(); -} - -internal sealed class RecordingConnectionBuilder : IConnectionBuilder -{ - private readonly List> _middleware = []; - private readonly IList? _callOrder; - - public RecordingConnectionBuilder(IServiceProvider applicationServices, IList? callOrder = null) - { - ApplicationServices = applicationServices; - _callOrder = callOrder; - } - - public IServiceProvider ApplicationServices { get; } - - public int MiddlewareRegistrationCount => _middleware.Count; - - public IConnectionBuilder Use(Func middleware) - { - _middleware.Add(middleware); - _callOrder?.Add("tls"); - return this; - } - - public ConnectionDelegate Build() - { - ConnectionDelegate application = static _ => Task.CompletedTask; - for (var index = _middleware.Count - 1; index >= 0; index--) - { - application = _middleware[index](application); - } - - return application; - } -} - -internal static class ClientConnectionOptionsTestExtensions -{ - public static void ApplyTo(this ClientConnectionOptions options, IConnectionBuilder builder) => - ConfigureConnectionBuilder(options, builder); - - [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ConfigureConnectionBuilder")] - private static extern void ConfigureConnectionBuilder(ClientConnectionOptions options, IConnectionBuilder builder); -} - -internal sealed class RecordingSiloBuilder : ISiloBuilder -{ - public RecordingSiloBuilder() - { - Services = new ServiceCollection(); - Configuration = new ConfigurationManager(); - } - - public IServiceCollection Services { get; } - - public IConfiguration Configuration { get; } - - public int SiloConnectionOptionsConfigurationCount => - Services.Count(service => service.ServiceType == typeof(IConfigureOptions)); - - public ServiceProvider BuildServiceProvider() => Services.BuildServiceProvider(); -} - -internal static class SiloConnectionOptionsTestExtensions -{ - public static void ApplyGatewayInboundTo(this SiloConnectionOptions options, IConnectionBuilder builder) => - ((SiloConnectionOptions.ISiloConnectionBuilderOptions)options).ConfigureGatewayInboundBuilder(builder); - - public static void ApplySiloInboundTo(this SiloConnectionOptions options, IConnectionBuilder builder) => - ((SiloConnectionOptions.ISiloConnectionBuilderOptions)options).ConfigureSiloInboundBuilder(builder); - - public static void ApplySiloOutboundTo(this SiloConnectionOptions options, IConnectionBuilder builder) => - ((SiloConnectionOptions.ISiloConnectionBuilderOptions)options).ConfigureSiloOutboundBuilder(builder); -} diff --git a/test/Orleans.Connections.Security.Tests/Orleans.Connections.Security.Tests.csproj b/test/Orleans.Connections.Security.Tests/Orleans.Connections.Security.Tests.csproj index de77b729beb..6840b3c446a 100644 --- a/test/Orleans.Connections.Security.Tests/Orleans.Connections.Security.Tests.csproj +++ b/test/Orleans.Connections.Security.Tests/Orleans.Connections.Security.Tests.csproj @@ -10,7 +10,6 @@ - diff --git a/test/Orleans.Connections.Security.Tests/PipeTestInfrastructure.cs b/test/Orleans.Connections.Security.Tests/PipeTestInfrastructure.cs deleted file mode 100644 index 68d6c0dc745..00000000000 --- a/test/Orleans.Connections.Security.Tests/PipeTestInfrastructure.cs +++ /dev/null @@ -1,309 +0,0 @@ -using System.Buffers; -using System.IO.Pipelines; - -namespace Orleans.Connections.Security.Tests; - -internal sealed class TestDuplexPipe(PipeReader input, PipeWriter output) : IDuplexPipe -{ - public PipeReader Input { get; } = input; - - public PipeWriter Output { get; } = output; -} - -internal sealed class ScriptedPipeReader(Func> readAsync) : PipeReader -{ - private readonly List _readCancellationTokens = []; - - public SequencePosition LastConsumed { get; private set; } - - public SequencePosition LastExamined { get; private set; } - - public int AdvanceCount { get; private set; } - - public int CompleteCount { get; private set; } - - public int CompleteAsyncCount { get; private set; } - - public IReadOnlyList ReadCancellationTokens => _readCancellationTokens; - - public override void AdvanceTo(SequencePosition consumed) => AdvanceTo(consumed, consumed); - - public override void AdvanceTo(SequencePosition consumed, SequencePosition examined) - { - LastConsumed = consumed; - LastExamined = examined; - AdvanceCount++; - } - - public override void CancelPendingRead() - { - } - - public override void Complete(Exception? exception = null) => CompleteCount++; - - public override ValueTask CompleteAsync(Exception? exception = null) - { - CompleteAsyncCount++; - return default; - } - - public override ValueTask ReadAsync(CancellationToken cancellationToken = default) - { - _readCancellationTokens.Add(cancellationToken); - return readAsync(cancellationToken); - } - - public override bool TryRead(out ReadResult result) - { - result = default; - return false; - } -} - -internal sealed class ControlledPipeReader : PipeReader -{ - private readonly TaskCompletionSource _result = - new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly TaskCompletionSource _readStarted = - new(TaskCreationOptions.RunContinuationsAsynchronously); - - public Task ReadStarted => _readStarted.Task; - - public SequencePosition LastConsumed { get; private set; } - - public int AdvanceCount { get; private set; } - - public CancellationToken LastReadCancellationToken { get; private set; } - - public void SetResult(ReadResult result) => _result.SetResult(result); - - public override void AdvanceTo(SequencePosition consumed) - { - LastConsumed = consumed; - AdvanceCount++; - } - - public override void AdvanceTo(SequencePosition consumed, SequencePosition examined) => AdvanceTo(consumed); - - public override void CancelPendingRead() => _result.TrySetCanceled(); - - public override void Complete(Exception? exception = null) - { - } - - public override ValueTask ReadAsync(CancellationToken cancellationToken = default) - { - LastReadCancellationToken = cancellationToken; - _readStarted.TrySetResult(); - return new(_result.Task); - } - - public override bool TryRead(out ReadResult result) - { - result = default; - return false; - } -} - -internal sealed class RecordingPipeWriter(FlushResult flushResult) : PipeWriter -{ - private byte[] _buffer = new byte[256]; - private int _written; - - public ReadOnlyMemory Written => _buffer.AsMemory(0, _written); - - public int FlushCount { get; private set; } - - public int CompleteCount { get; private set; } - - public int CompleteAsyncCount { get; private set; } - - public CancellationToken LastFlushCancellationToken { get; private set; } - - public override void Advance(int bytes) - { - ArgumentOutOfRangeException.ThrowIfNegative(bytes); - if (_written + bytes > _buffer.Length) - { - throw new InvalidOperationException("Advanced beyond the supplied buffer."); - } - - _written += bytes; - } - - public override void CancelPendingFlush() - { - } - - public override void Complete(Exception? exception = null) => CompleteCount++; - - public override ValueTask CompleteAsync(Exception? exception = null) - { - CompleteAsyncCount++; - return default; - } - - public override ValueTask FlushAsync(CancellationToken cancellationToken = default) - { - LastFlushCancellationToken = cancellationToken; - FlushCount++; - return new(flushResult); - } - - public override Memory GetMemory(int sizeHint = 0) - { - EnsureCapacity(sizeHint); - return _buffer.AsMemory(_written); - } - - public override Span GetSpan(int sizeHint = 0) - { - EnsureCapacity(sizeHint); - return _buffer.AsSpan(_written); - } - - private void EnsureCapacity(int sizeHint) - { - var required = _written + Math.Max(sizeHint, 1); - if (required > _buffer.Length) - { - Array.Resize(ref _buffer, Math.Max(required, _buffer.Length * 2)); - } - } -} - -internal sealed class TrackingStream(Stream inner, bool disposeInner = false) : Stream -{ - private bool _disposed; - - public int DisposeCount { get; private set; } - - public int DisposeAsyncCount { get; private set; } - - private Stream Inner - { - get - { - ObjectDisposedException.ThrowIf(_disposed, this); - return inner; - } - } - - public override bool CanRead => Inner.CanRead; - - public override bool CanSeek => Inner.CanSeek; - - public override bool CanWrite => Inner.CanWrite; - - public override long Length => Inner.Length; - - public override long Position - { - get => Inner.Position; - set => Inner.Position = value; - } - - public override void Flush() => Inner.Flush(); - - public override Task FlushAsync(CancellationToken cancellationToken) => Inner.FlushAsync(cancellationToken); - - public override int Read(byte[] buffer, int offset, int count) => Inner.Read(buffer, offset, count); - - public override int Read(Span buffer) => Inner.Read(buffer); - - public override Task ReadAsync( - byte[] buffer, - int offset, - int count, - CancellationToken cancellationToken) => - Inner.ReadAsync(buffer, offset, count, cancellationToken); - - public override ValueTask ReadAsync( - Memory buffer, - CancellationToken cancellationToken = default) => - Inner.ReadAsync(buffer, cancellationToken); - - public override long Seek(long offset, SeekOrigin origin) => Inner.Seek(offset, origin); - - public override void SetLength(long value) => Inner.SetLength(value); - - public override void Write(byte[] buffer, int offset, int count) => Inner.Write(buffer, offset, count); - - public override void Write(ReadOnlySpan buffer) => Inner.Write(buffer); - - public override Task WriteAsync( - byte[] buffer, - int offset, - int count, - CancellationToken cancellationToken) => - Inner.WriteAsync(buffer, offset, count, cancellationToken); - - public override ValueTask WriteAsync( - ReadOnlyMemory buffer, - CancellationToken cancellationToken = default) => - Inner.WriteAsync(buffer, cancellationToken); - - protected override void Dispose(bool disposing) - { - if (disposing && !_disposed) - { - _disposed = true; - DisposeCount++; - if (disposeInner) - { - inner.Dispose(); - } - } - - base.Dispose(disposing); - } - - public override async ValueTask DisposeAsync() - { - if (_disposed) - { - return; - } - - _disposed = true; - DisposeAsyncCount++; - if (disposeInner) - { - await inner.DisposeAsync(); - } - - GC.SuppressFinalize(this); - } -} - -internal static class SequenceFactory -{ - public static ReadOnlySequence Create(params byte[][] segments) - { - ArgumentOutOfRangeException.ThrowIfZero(segments.Length); - - var first = new ByteSequenceSegment(segments[0]); - var last = first; - for (var i = 1; i < segments.Length; i++) - { - last = last.Append(segments[i]); - } - - return new(first, 0, last, last.Memory.Length); - } - - private sealed class ByteSequenceSegment : ReadOnlySequenceSegment - { - public ByteSequenceSegment(ReadOnlyMemory memory) => Memory = memory; - - public ByteSequenceSegment Append(ReadOnlyMemory memory) - { - var next = new ByteSequenceSegment(memory) - { - RunningIndex = RunningIndex + Memory.Length - }; - Next = next; - return next; - } - } -} diff --git a/test/Orleans.Connections.Security.Tests/SiloHostingExtensionsTests.cs b/test/Orleans.Connections.Security.Tests/SiloHostingExtensionsTests.cs deleted file mode 100644 index 9aeb76d54e3..00000000000 --- a/test/Orleans.Connections.Security.Tests/SiloHostingExtensionsTests.cs +++ /dev/null @@ -1,322 +0,0 @@ -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; -using Microsoft.AspNetCore.Connections; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Orleans.Configuration; -using Orleans.Connections.Security; -using Orleans.Hosting; -using Xunit; - -namespace Orleans.Connections.Security.Tests; - -public class SiloHostingExtensionsTests -{ - [Fact] - public void UseTls_NullCertificateWithConfigureAction_ThrowsArgumentNullExceptionForCertificate() - { - var builder = new RecordingSiloBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls((X509Certificate2)null!, static _ => { })); - - Assert.Equal("certificate", exception.ParamName); - Assert.Equal(0, builder.SiloConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_NullCertificateWithoutConfigureAction_ThrowsArgumentNullExceptionForCertificate() - { - var builder = new RecordingSiloBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls((X509Certificate2)null!)); - - Assert.Equal("certificate", exception.ParamName); - Assert.Equal(0, builder.SiloConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_NullConfigureAction_ThrowsArgumentNullExceptionForConfigureOptions() - { - var builder = new RecordingSiloBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls((Action)null!)); - - Assert.Equal("configureOptions", exception.ParamName); - Assert.Equal(0, builder.SiloConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_CertificateOverloadWithNullConfigureAction_ThrowsArgumentNullExceptionForConfigureOptions() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("silo-null-configure.test"); - var builder = new RecordingSiloBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls(certificate, null!)); - - Assert.Equal("configureOptions", exception.ParamName); - Assert.Equal(0, builder.SiloConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_StoreOverloadWithNullConfigureAction_ThrowsBeforeAccessingCertificateStore() - { - var builder = new RecordingSiloBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls( - StoreName.My, - "unused.test", - allowInvalid: false, - StoreLocation.CurrentUser, - null!)); - - Assert.Equal("configureOptions", exception.ParamName); - Assert.Equal(0, builder.SiloConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_WithoutLocalCertificateOrSelector_ThrowsConfigurationFailure() - { - var builder = new RecordingSiloBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls(static _ => { })); - - Assert.Equal("No certificate specified", exception.Message); - Assert.Equal(0, builder.SiloConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_CertificateWithoutPrivateKey_ThrowsArgumentExceptionForCertificate() - { - using var certificate = TestCertificateHelper.CreateCertificateWithoutPrivateKey("silo-no-private-key.test"); - var builder = new RecordingSiloBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls(certificate, static _ => { })); - - Assert.Equal("certificate", exception.ParamName); - Assert.Contains("does not contain a private key", exception.Message); - Assert.Equal(0, builder.SiloConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_ConfiguredCertificateWithoutPrivateKey_ThrowsArgumentExceptionForTlsOptionsLocalCertificate() - { - using var certificate = TestCertificateHelper.CreateCertificateWithoutPrivateKey("silo-configured-no-private-key.test"); - var builder = new RecordingSiloBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls(options => options.LocalCertificate = certificate)); - - Assert.Equal("TlsOptions.LocalCertificate", exception.ParamName); - Assert.Contains("does not contain a private key", exception.Message); - Assert.Equal(0, builder.SiloConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_ServerCertificateSelectorWithoutLocalCertificate_IsAccepted() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("silo-selector.test"); - var builder = new RecordingSiloBuilder(); - Func expectedSelector = (_, _) => certificate; - TlsOptions? configuredOptions = null; - - var result = builder.UseTls( - options => - { - configuredOptions = options; - options.LocalServerCertificateSelector = expectedSelector; - }); - - Assert.Same(builder, result); - var actual = Assert.IsType(configuredOptions); - Assert.Null(actual.LocalCertificate); - Assert.Same(expectedSelector, actual.LocalServerCertificateSelector); - Assert.Equal(1, builder.SiloConnectionOptionsConfigurationCount); - using var provider = builder.BuildServiceProvider(); - Assert.IsType( - provider.GetRequiredService>().Value); - } - - [Fact] - public void UseTls_CertificateOverload_AssignsCertificateBeforeInvokingConfigureAction() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("silo-order.test"); - var builder = new RecordingSiloBuilder(); - X509Certificate2? certificateObservedByConfigureAction = null; - - var result = builder.UseTls( - certificate, - options => certificateObservedByConfigureAction = options.LocalCertificate); - - Assert.Same(builder, result); - Assert.Same(certificate, certificateObservedByConfigureAction); - Assert.Equal(1, builder.SiloConnectionOptionsConfigurationCount); - } - - [Fact] - public void UseTls_ConfigureActionMutations_AppearExactlyOnCapturedTlsOptionsAndResolvedConnectionOptions() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("silo-options.test"); - var builder = new RecordingSiloBuilder(); - var expectedTimeout = TimeSpan.FromMilliseconds(2_137); - Func expectedSelector = (_, _) => certificate; - RemoteCertificateValidator expectedValidator = static (_, _, errors) => - errors == System.Net.Security.SslPolicyErrors.RemoteCertificateChainErrors; - TlsOptions? configuredOptions = null; - - builder.UseTls( - certificate, - options => - { - configuredOptions = options; - options.HandshakeTimeout = expectedTimeout; - options.RemoteCertificateMode = RemoteCertificateMode.RequireCertificate; - options.SslProtocols = SslProtocols.Tls12; - options.LocalServerCertificateSelector = expectedSelector; - options.RemoteCertificateValidation = expectedValidator; - }); - - var actual = Assert.IsType(configuredOptions); - Assert.Same(certificate, actual.LocalCertificate); - Assert.Equal(expectedTimeout, actual.HandshakeTimeout); - Assert.Equal(RemoteCertificateMode.RequireCertificate, actual.RemoteCertificateMode); - Assert.Equal(SslProtocols.Tls12, actual.SslProtocols); - Assert.Same(expectedSelector, actual.LocalServerCertificateSelector); - Assert.Same(expectedValidator, actual.RemoteCertificateValidation); - using var provider = builder.BuildServiceProvider(); - var connectionOptions = provider.GetRequiredService>().Value; - var gatewayBuilder = new RecordingConnectionBuilder(provider); - var siloInboundBuilder = new RecordingConnectionBuilder(provider); - var siloOutboundBuilder = new RecordingConnectionBuilder(provider); - connectionOptions.ApplyGatewayInboundTo(gatewayBuilder); - connectionOptions.ApplySiloInboundTo(siloInboundBuilder); - connectionOptions.ApplySiloOutboundTo(siloOutboundBuilder); - Assert.Equal(1, gatewayBuilder.MiddlewareRegistrationCount); - Assert.Equal(1, siloInboundBuilder.MiddlewareRegistrationCount); - Assert.Equal(1, siloOutboundBuilder.MiddlewareRegistrationCount); - } - - [Fact] - public void UseTls_RegistersExactlyOneGatewayInboundServerTlsConnectionCallback() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("silo-gateway-registration.test"); - var builder = new RecordingSiloBuilder(); - - builder.UseTls(certificate); - - Assert.Equal(1, builder.SiloConnectionOptionsConfigurationCount); - using var provider = builder.BuildServiceProvider(); - var connectionOptions = provider.GetRequiredService>().Value; - var connectionBuilder = new RecordingConnectionBuilder(provider); - connectionOptions.ApplyGatewayInboundTo(connectionBuilder); - Assert.Equal(1, connectionBuilder.MiddlewareRegistrationCount); - } - - [Fact] - public void UseTls_RegistersExactlyOneSiloInboundServerTlsConnectionCallback() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("silo-inbound-registration.test"); - var builder = new RecordingSiloBuilder(); - - builder.UseTls(certificate); - - Assert.Equal(1, builder.SiloConnectionOptionsConfigurationCount); - using var provider = builder.BuildServiceProvider(); - var connectionOptions = provider.GetRequiredService>().Value; - var connectionBuilder = new RecordingConnectionBuilder(provider); - connectionOptions.ApplySiloInboundTo(connectionBuilder); - Assert.Equal(1, connectionBuilder.MiddlewareRegistrationCount); - } - - [Fact] - public void UseTls_RegistersExactlyOneSiloOutboundClientTlsConnectionCallback() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("silo-outbound-registration.test"); - var builder = new RecordingSiloBuilder(); - - builder.UseTls(certificate); - - Assert.Equal(1, builder.SiloConnectionOptionsConfigurationCount); - using var provider = builder.BuildServiceProvider(); - var connectionOptions = provider.GetRequiredService>().Value; - var connectionBuilder = new RecordingConnectionBuilder(provider); - connectionOptions.ApplySiloOutboundTo(connectionBuilder); - Assert.Equal(1, connectionBuilder.MiddlewareRegistrationCount); - } - - [Fact] - public void UseTls_AppendsAllTlsCallbacksWithoutReplacingExistingSiloConnectionConfiguration() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("silo-composition.test"); - var builder = new RecordingSiloBuilder(); - var gatewayOrder = new List(); - var siloInboundOrder = new List(); - var siloOutboundOrder = new List(); - var gatewayInvocations = 0; - var siloInboundInvocations = 0; - var siloOutboundInvocations = 0; - builder.Configure( - options => - { - options.ConfigureGatewayInboundConnection( - _ => - { - gatewayInvocations++; - gatewayOrder.Add("existing"); - }); - options.ConfigureSiloInboundConnection( - _ => - { - siloInboundInvocations++; - siloInboundOrder.Add("existing"); - }); - options.ConfigureSiloOutboundConnection( - _ => - { - siloOutboundInvocations++; - siloOutboundOrder.Add("existing"); - }); - }); - - builder.UseTls(certificate); - - Assert.Equal(2, builder.SiloConnectionOptionsConfigurationCount); - using var provider = builder.BuildServiceProvider(); - var connectionOptions = provider.GetRequiredService>().Value; - var gatewayBuilder = new RecordingConnectionBuilder(provider, gatewayOrder); - var siloInboundBuilder = new RecordingConnectionBuilder(provider, siloInboundOrder); - var siloOutboundBuilder = new RecordingConnectionBuilder(provider, siloOutboundOrder); - connectionOptions.ApplyGatewayInboundTo(gatewayBuilder); - connectionOptions.ApplySiloInboundTo(siloInboundBuilder); - connectionOptions.ApplySiloOutboundTo(siloOutboundBuilder); - Assert.Equal(1, gatewayInvocations); - Assert.Equal(1, siloInboundInvocations); - Assert.Equal(1, siloOutboundInvocations); - Assert.Equal(1, gatewayBuilder.MiddlewareRegistrationCount); - Assert.Equal(1, siloInboundBuilder.MiddlewareRegistrationCount); - Assert.Equal(1, siloOutboundBuilder.MiddlewareRegistrationCount); - Assert.Equal(["existing", "tls"], gatewayOrder); - Assert.Equal(["existing", "tls"], siloInboundOrder); - Assert.Equal(["existing", "tls"], siloOutboundOrder); - } - - [Fact] - public void UseTls_CertificateWithoutPrivateKeyAndWithoutConfigureAction_ThrowsArgumentExceptionForCertificate() - { - using var certificate = TestCertificateHelper.CreateCertificateWithoutPrivateKey("silo-no-private-key-no-configure.test"); - var builder = new RecordingSiloBuilder(); - - var exception = Assert.Throws( - () => builder.UseTls(certificate)); - - Assert.Equal("certificate", exception.ParamName); - Assert.Contains("does not contain a private key", exception.Message); - Assert.Equal(0, builder.SiloConnectionOptionsConfigurationCount); - } -} diff --git a/test/Orleans.Connections.Security.Tests/TaskToApmTests.cs b/test/Orleans.Connections.Security.Tests/TaskToApmTests.cs deleted file mode 100644 index ab6f8abe53d..00000000000 --- a/test/Orleans.Connections.Security.Tests/TaskToApmTests.cs +++ /dev/null @@ -1,227 +0,0 @@ -using Orleans.Connections.Security; -using Xunit; -using TaskToApm = Orleans.Connections.Security.DuplexPipeStream.TaskToApm; - -namespace Orleans.Connections.Security.Tests; - -public class TaskToApmTests -{ - [Fact] - public void Begin_CompletedTask_InvokesCallbackSynchronouslyWithExactState() - { - var state = new object(); - IAsyncResult? callbackResult = null; - var callbackCount = 0; - var beginReturned = false; - - var result = TaskToApm.Begin( - Task.CompletedTask, - value => - { - Assert.False(beginReturned); - callbackResult = value; - callbackCount++; - }, - state); - beginReturned = true; - - Assert.Same(result, callbackResult); - Assert.Same(state, result.AsyncState); - Assert.True(result.CompletedSynchronously); - Assert.True(result.IsCompleted); - Assert.Equal(1, callbackCount); - } - - [Fact] - public async Task Begin_DelayedTask_InvokesCallbackAfterCompletionWithExactState() - { - var operation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var callback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var state = new object(); - - var result = TaskToApm.Begin(operation.Task, callback.SetResult, state); - - Assert.False(result.CompletedSynchronously); - Assert.False(result.IsCompleted); - Assert.False(callback.Task.IsCompleted); - operation.SetResult(); - var callbackResult = await callback.Task.WaitAsync(TestContext.Current.CancellationToken); - - Assert.Same(result, callbackResult); - Assert.Same(state, callbackResult.AsyncState); - Assert.False(callbackResult.CompletedSynchronously); - Assert.True(callbackResult.IsCompleted); - } - - [Fact] - public async Task AsyncWaitHandle_TracksDelayedTaskCompletion() - { - var operation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var result = TaskToApm.Begin(operation.Task, callback: null, state: null); - using var waitHandle = result.AsyncWaitHandle; - - Assert.False(waitHandle.WaitOne(0)); - operation.SetResult(); - await operation.Task; - - Assert.True(waitHandle.WaitOne(0)); - Assert.True(result.IsCompleted); - Assert.False(result.CompletedSynchronously); - } - - [Fact] - public void GetTask_ReturnsExactWrappedTask() - { - var task = Task.FromResult(873); - var result = TaskToApm.Begin(task, callback: null, state: new object()); - - Assert.Same(task, TaskToApm.GetTask(result)); - Assert.True(result.CompletedSynchronously); - } - - [Fact] - public void End_CompletedNonGenericTask_ReturnsSuccessfully() - { - var task = Task.CompletedTask; - var result = TaskToApm.Begin(task, callback: null, state: null); - - TaskToApm.End(result); - - Assert.True(result.IsCompleted); - Assert.Same(task, TaskToApm.GetTask(result)); - } - - [Fact] - public void End_CompletedGenericTask_ReturnsExactResult() - { - var task = Task.FromResult("exact-result"); - var result = TaskToApm.Begin(task, callback: null, state: null); - - var value = TaskToApm.End(result); - - Assert.Equal("exact-result", value); - Assert.Same(task, TaskToApm.GetTask(result)); - } - - [Fact] - public async Task End_DelayedNonGenericTask_WaitsForCompletion() - { - var operation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var result = TaskToApm.Begin(operation.Task, callback: null, state: null); - var endStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var endTask = Task.Run( - () => - { - endStarted.SetResult(); - TaskToApm.End(result); - }, - TestContext.Current.CancellationToken); - - await endStarted.Task.WaitAsync(TestContext.Current.CancellationToken); - Assert.False(endTask.IsCompleted); - operation.SetResult(); - await endTask.WaitAsync(TestContext.Current.CancellationToken); - } - - [Fact] - public async Task End_DelayedGenericTask_WaitsAndReturnsExactResult() - { - var operation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var result = TaskToApm.Begin(operation.Task, callback: null, state: null); - var endStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var endTask = Task.Run( - () => - { - endStarted.SetResult(); - return TaskToApm.End(result); - }, - TestContext.Current.CancellationToken); - - await endStarted.Task.WaitAsync(TestContext.Current.CancellationToken); - Assert.False(endTask.IsCompleted); - operation.SetResult(1_237); - Assert.Equal(1_237, await endTask.WaitAsync(TestContext.Current.CancellationToken)); - } - - [Fact] - public void End_FaultedTasks_PropagateOriginalException() - { - var nonGenericException = new InvalidOperationException("non-generic failure"); - var genericException = new FormatException("generic failure"); - var nonGeneric = TaskToApm.Begin( - Task.FromException(nonGenericException), - callback: null, - state: null); - var generic = TaskToApm.Begin( - Task.FromException(genericException), - callback: null, - state: null); - - Assert.Same(nonGenericException, Assert.Throws(() => TaskToApm.End(nonGeneric))); - Assert.Same(genericException, Assert.Throws(() => TaskToApm.End(generic))); - Assert.True(nonGeneric.IsCompleted); - Assert.True(generic.IsCompleted); - } - - [Fact] - public void End_CanceledTasks_PropagateCancellation() - { - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); - var nonGeneric = TaskToApm.Begin( - Task.FromCanceled(cancellation.Token), - callback: null, - state: null); - var generic = TaskToApm.Begin( - Task.FromCanceled(cancellation.Token), - callback: null, - state: null); - - var nonGenericException = Assert.Throws(() => TaskToApm.End(nonGeneric)); - var genericException = Assert.Throws(() => TaskToApm.End(generic)); - - Assert.Equal(cancellation.Token, nonGenericException.CancellationToken); - Assert.Equal(cancellation.Token, genericException.CancellationToken); - } - - [Fact] - public void End_NullResult_ThrowsArgumentNullException() - { - var nonGeneric = Assert.Throws(() => TaskToApm.End(null!)); - var generic = Assert.Throws(() => TaskToApm.End(null!)); - - Assert.Equal("asyncResult", nonGeneric.ParamName); - Assert.Equal("asyncResult", generic.ParamName); - } - - [Fact] - public void End_ForeignResult_ThrowsArgumentException() - { - IAsyncResult foreign = Task.CompletedTask; - - var nonGeneric = Assert.Throws(() => TaskToApm.End(foreign)); - var generic = Assert.Throws(() => TaskToApm.End(foreign)); - - Assert.Equal("asyncResult", nonGeneric.ParamName); - Assert.Equal("asyncResult", generic.ParamName); - } - - [Fact] - public void End_GenericWithWrongTaskResultType_ThrowsArgumentException() - { - var task = Task.FromResult("wrong type"); - var result = TaskToApm.Begin(task, callback: null, state: null); - - var exception = Assert.Throws(() => TaskToApm.End(result)); - - Assert.Equal("asyncResult", exception.ParamName); - Assert.Same(task, TaskToApm.GetTask(result)); - } - - [Fact] - public void GetTask_NullOrForeignResult_ReturnsNull() - { - Assert.Null(TaskToApm.GetTask(null!)); - Assert.Null(TaskToApm.GetTask(Task.CompletedTask)); - } -} diff --git a/test/Orleans.Connections.Security.Tests/TlsClientAuthenticationOptionsTests.cs b/test/Orleans.Connections.Security.Tests/TlsClientAuthenticationOptionsTests.cs index 3c1b24c98d7..b211995d3da 100644 --- a/test/Orleans.Connections.Security.Tests/TlsClientAuthenticationOptionsTests.cs +++ b/test/Orleans.Connections.Security.Tests/TlsClientAuthenticationOptionsTests.cs @@ -1,6 +1,6 @@ using System.Net.Security; using System.Security.Cryptography.X509Certificates; -using Orleans.Connections.Security; +using Orleans.Connections.Transport.Security; using Xunit; namespace Orleans.Connections.Security.Tests; diff --git a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs index e98419d6f32..e780e87cb7e 100644 --- a/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs +++ b/test/Orleans.Connections.Security.Tests/TlsConnectionTests.cs @@ -1,5 +1,11 @@ +using System.Collections.Concurrent; +using System.Net.Security; +using System.Reflection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; +using Orleans.Connections.Transport.Security; +using Orleans.Hosting; +using Orleans.Runtime; using Orleans.TestingHost; using TestExtensions; using Xunit; @@ -8,17 +14,17 @@ namespace Orleans.Connections.Security.Tests { /// /// Tests for TLS (Transport Layer Security) support in Orleans connections. - /// + /// /// Orleans supports TLS encryption for: /// - Client-to-silo connections (gateway connections) /// - Silo-to-silo connections (membership protocol) - /// + /// /// Key features tested: /// - Certificate creation and encoding/decoding /// - Mutual TLS authentication (mTLS) with client certificates /// - Different certificate validation modes /// - End-to-end encrypted communication - /// + /// /// TLS is essential for: /// - Securing Orleans deployments in untrusted networks /// - Meeting compliance requirements (HIPAA, PCI-DSS, etc.) @@ -35,6 +41,7 @@ public class TlsConnectionTests private const string CertificateSubjectName = "fakedomain.faketld"; private const string CertificateConfigKey = "certificate"; private const string ClientCertificateModeKey = "CertificateMode"; + private static readonly ConcurrentBag ServerHandshakeFeatures = new(); /// /// Tests the certificate utility functions for creating self-signed certificates. @@ -54,6 +61,74 @@ public void CanCreateCertificates() Assert.Equal(original, decoded); } + [Fact] + public void ClientTlsValidation_AllowsRequiredSelectorOnlyCredentials() + { + var options = new TlsOptions + { + ClientCertificateMode = RemoteCertificateMode.RequireCertificate, + LocalClientCertificateSelector = static (_, _, _, _, _) => null + }; + + CreateTlsOptionsValidator(options, isServer: false).ValidateConfiguration(); + } + + [Fact] + public void ServerTlsValidation_AllowsSelectorOnlyCredentials() + { + var options = new TlsOptions + { + LocalServerCertificateSelector = static (_, _) => null + }; + + CreateTlsOptionsValidator(options, isServer: true).ValidateConfiguration(); + } + + private static IConfigurationValidator CreateTlsOptionsValidator(TlsOptions options, bool isServer) + { + var type = typeof(ClientTlsHostingExtensions).GetNestedType("TlsOptionsValidator", BindingFlags.NonPublic)!; + return (IConfigurationValidator)Activator.CreateInstance(type, options, isServer)!; + } + + [Fact] + public async Task TlsEndToEndPreservesServerNameIndication() + { + while (ServerHandshakeFeatures.TryTake(out _)) + { + } + + TestCluster? testCluster = default; + try + { + var builder = new TestClusterBuilder() + .AddSiloBuilderConfigurator() + .AddClientBuilderConfigurator(); + + var certificate = TestCertificateHelper.CreateSelfSignedCertificate( + CertificateSubjectName, + new[] { TestCertificateHelper.ServerAuthenticationOid }); + + builder.Properties[CertificateConfigKey] = TestCertificateHelper.ConvertToBase64(certificate); + builder.Properties[ClientCertificateModeKey] = RemoteCertificateMode.NoCertificate.ToString(); + + testCluster = builder.Build(); + await testCluster.DeployAsync(TestContext.Current.CancellationToken); + + var grain = testCluster.Client.GetGrain("pingu"); + Assert.Equal("secret chit chat", await grain.Echo("secret chit chat")); + + Assert.Contains(ServerHandshakeFeatures, feature => feature.HostName == CertificateSubjectName); + } + finally + { + if (testCluster != null) + { + await testCluster.StopAllSilosAsync(TestContext.Current.CancellationToken); + testCluster.Dispose(); + } + } + } + /// /// Configures TLS for Orleans clients in the test cluster. /// Sets up: @@ -134,12 +209,50 @@ public void Configure(IHostBuilder hostBuilder) } } + private class TlsServerSniConfigurator : IHostConfigurator + { + public void Configure(IHostBuilder hostBuilder) + { + var config = hostBuilder.GetConfiguration(); + var encodedCertificate = config[CertificateConfigKey]; + var localCertificate = TestCertificateHelper.ConvertFromBase64(encodedCertificate!); + + hostBuilder.UseOrleans((ctx, siloBuilder) => + { + siloBuilder.UseTls(localCertificate, options => + { + options.SslProtocols = System.Security.Authentication.SslProtocols.Tls12; + options.AllowAnyRemoteCertificate(); + options.RemoteCertificateMode = RemoteCertificateMode.NoCertificate; + options.ClientCertificateMode = RemoteCertificateMode.NoCertificate; + options.LocalServerCertificateSelector = (connection, _) => + { + Assert.NotNull(connection.Features.Get()); + return localCertificate; + }; + options.OnAuthenticateAsClient = (connection, sslOptions) => + { + sslOptions.TargetHost = CertificateSubjectName; + }; + options.OnAuthenticateAsServer = (connection, sslOptions) => + { + var feature = connection.Features.Get(); + if (feature is not null) + { + ServerHandshakeFeatures.Add(feature); + } + }; + }); + }); + } + } + /// /// End-to-end test of TLS communication with various certificate configurations. /// Tests different combinations of: /// - Certificate OIDs (null, server-only, or both client and server authentication) /// - Certificate modes (NoCertificate, AllowCertificate, RequireCertificate) - /// + /// /// Verifies that: /// - TLS connections are established successfully /// - Grain calls work over encrypted connections diff --git a/test/Orleans.Connections.Security.Tests/TlsMiddlewareTests.cs b/test/Orleans.Connections.Security.Tests/TlsMiddlewareTests.cs deleted file mode 100644 index 39028489a1f..00000000000 --- a/test/Orleans.Connections.Security.Tests/TlsMiddlewareTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.IO.Pipelines; -using System.Security.Authentication; -using Microsoft.AspNetCore.Connections; -using Orleans.Connections.Security; -using Xunit; - -namespace Orleans.Connections.Security.Tests; - -public class TlsMiddlewareTests -{ - [Fact] - public async Task ClientMiddleware_InvokesAuthenticationCallbackAfterApplyingBaseOptions() - { - var context = CreateConnectionContext(); - var callbackInvoked = false; - var nextInvoked = false; - ConnectionContext? callbackContext = null; - TlsClientAuthenticationOptions? callbackOptions = null; - var options = new TlsOptions - { - HandshakeTimeout = Timeout.InfiniteTimeSpan, - RemoteCertificateMode = RemoteCertificateMode.NoCertificate, - SslProtocols = SslProtocols.Tls12, - OnAuthenticateAsClient = (actualContext, authenticationOptions) => - { - callbackInvoked = true; - callbackContext = actualContext; - callbackOptions = authenticationOptions; - throw new ExpectedAuthenticationException(); - } - }; - var middleware = new TlsClientConnectionMiddleware(options, loggerFactory: null); - - await middleware.OnConnectionAsync( - context, - _ => - { - nextInvoked = true; - return Task.CompletedTask; - }); - - Assert.True(callbackInvoked); - Assert.False(nextInvoked); - Assert.Same(context, callbackContext); - var receivedOptions = Assert.IsType(callbackOptions); - Assert.Equal(SslProtocols.Tls12, receivedOptions.EnabledSslProtocols); - Assert.Null(receivedOptions.ClientCertificates); - } - - [Fact] - public async Task ServerMiddleware_InvokesAuthenticationCallbackAfterApplyingBaseOptions() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("middleware-server.test"); - var context = CreateConnectionContext(); - var callbackInvoked = false; - var nextInvoked = false; - ConnectionContext? callbackContext = null; - TlsServerAuthenticationOptions? callbackOptions = null; - var options = new TlsOptions - { - HandshakeTimeout = Timeout.InfiniteTimeSpan, - LocalCertificate = certificate, - RemoteCertificateMode = RemoteCertificateMode.NoCertificate, - SslProtocols = SslProtocols.Tls12, - OnAuthenticateAsServer = (actualContext, authenticationOptions) => - { - callbackInvoked = true; - callbackContext = actualContext; - callbackOptions = authenticationOptions; - throw new ExpectedAuthenticationException(); - } - }; - var middleware = new TlsServerConnectionMiddleware(options, loggerFactory: null); - - await middleware.OnConnectionAsync( - context, - _ => - { - nextInvoked = true; - return Task.CompletedTask; - }); - - Assert.True(callbackInvoked); - Assert.False(nextInvoked); - Assert.Same(context, callbackContext); - var receivedOptions = Assert.IsType(callbackOptions); - Assert.Equal(SslProtocols.Tls12, receivedOptions.EnabledSslProtocols); - Assert.False(receivedOptions.ClientCertificateRequired); - Assert.Null(receivedOptions.ServerCertificate); - Assert.NotNull(receivedOptions.ServerCertificateSelectionCallback); - } - - private static DefaultConnectionContext CreateConnectionContext() => - new() - { - Transport = new TestDuplexPipe(new Pipe().Reader, new Pipe().Writer) - }; - - private sealed class ExpectedAuthenticationException : Exception - { - } -} diff --git a/test/Orleans.Connections.Security.Tests/TlsOptionsTests.cs b/test/Orleans.Connections.Security.Tests/TlsOptionsTests.cs index 0b775356ae3..14b181a42f6 100644 --- a/test/Orleans.Connections.Security.Tests/TlsOptionsTests.cs +++ b/test/Orleans.Connections.Security.Tests/TlsOptionsTests.cs @@ -1,41 +1,59 @@ -using System.Net.Security; -using System.Security.Authentication; -using System.Security.Cryptography.X509Certificates; -using Microsoft.AspNetCore.Connections; -using Orleans.Connections.Security; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Orleans.Connections.Transport.Security; +using Orleans.Hosting; +using TestExtensions; using Xunit; namespace Orleans.Connections.Security.Tests; +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Security")] public class TlsOptionsTests { [Fact] - public void Constructor_SetsEveryPublicDefaultToItsDocumentedValue() + public void SiloTlsConfiguration_AppliesOncePerConnectionRole() { - var options = new TlsOptions(); + var services = new ServiceCollection(); + var configuredOptions = new List(); + var authenticationCount = 0; + var builder = new TestSiloBuilder(services); + builder.UseTls(options => + { + configuredOptions.Add(options); + options.OnAuthenticateAsServer += (_, _) => authenticationCount++; + }); + using var provider = services.BuildServiceProvider(); + var monitor = provider.GetRequiredService>(); + var defaultOptions = monitor.CurrentValue; + var siloOptions = monitor.Get("silo"); + var gatewayOptions = monitor.Get("gateway"); + + Assert.Equal([defaultOptions, siloOptions, gatewayOptions], configuredOptions); + Assert.Same(defaultOptions, monitor.Get(Options.DefaultName)); + Assert.Same(siloOptions, monitor.Get("silo")); + Assert.Same(gatewayOptions, monitor.Get("gateway")); + Assert.Null(monitor.Get("other").OnAuthenticateAsServer); + foreach (var options in configuredOptions) + { + Assert.NotNull(options.OnAuthenticateAsServer); + options.OnAuthenticateAsServer(null!, new TlsServerAuthenticationOptions()); + } - Assert.Equal(TimeSpan.FromSeconds(10), options.HandshakeTimeout); - Assert.Null(options.LocalCertificate); - Assert.Null(options.LocalServerCertificateSelector); - Assert.Null(options.LocalClientCertificateSelector); - Assert.Equal(RemoteCertificateMode.RequireCertificate, options.RemoteCertificateMode); - Assert.Equal(RemoteCertificateMode.AllowCertificate, options.ClientCertificateMode); - Assert.Null(options.RemoteCertificateValidation); - Assert.Equal(SslProtocols.Tls12 | SslProtocols.Tls13, options.SslProtocols); - Assert.False(options.CheckCertificateRevocation); - Assert.Null(options.OnAuthenticateAsServer); - Assert.Null(options.OnAuthenticateAsClient); + Assert.Equal(3, authenticationCount); + Assert.Equal(3, configuredOptions.Count); } [Fact] - public void HandshakeTimeout_PositiveValue_IsStoredExactly() + public void CertificateModes_DefaultToRequiredPeerAndOptionalLocalClientCertificate() { - var options = new TlsOptions - { - HandshakeTimeout = TimeSpan.FromMilliseconds(1_237) - }; + var options = new TlsOptions(); - Assert.Equal(TimeSpan.FromMilliseconds(1_237), options.HandshakeTimeout); + Assert.Equal(RemoteCertificateMode.RequireCertificate, options.RemoteCertificateMode); + Assert.Equal(RemoteCertificateMode.AllowCertificate, options.ClientCertificateMode); } [Fact] @@ -80,130 +98,28 @@ public void HandshakeTimeout_FirstUnsupportedFiniteValue_ThrowsArgumentOutOfRang () => options.HandshakeTimeout = rejectedValue); Assert.Equal("value", exception.ParamName); - Assert.Null(exception.ActualValue); Assert.Contains("must be positive and no greater than", exception.Message); Assert.Equal(TimeSpan.FromSeconds(10), options.HandshakeTimeout); } - [Fact] - public void HandshakeTimeout_Zero_ThrowsArgumentOutOfRangeException() - { - var options = new TlsOptions(); - - var exception = Assert.Throws( - () => options.HandshakeTimeout = TimeSpan.Zero); - - Assert.Equal("value", exception.ParamName); - Assert.Null(exception.ActualValue); - Assert.Contains("HandshakeTimeout must be positive", exception.Message); - Assert.Equal(TimeSpan.FromSeconds(10), options.HandshakeTimeout); - } - - [Fact] - public void HandshakeTimeout_NegativeFiniteValue_ThrowsArgumentOutOfRangeException() + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void HandshakeTimeout_NonPositiveFiniteValue_ThrowsArgumentOutOfRangeException(int ticks) { var options = new TlsOptions(); - var rejectedValue = TimeSpan.FromTicks(-2); var exception = Assert.Throws( - () => options.HandshakeTimeout = rejectedValue); + () => options.HandshakeTimeout = TimeSpan.FromTicks(ticks)); Assert.Equal("value", exception.ParamName); - Assert.Null(exception.ActualValue); Assert.Contains("HandshakeTimeout must be positive", exception.Message); Assert.Equal(TimeSpan.FromSeconds(10), options.HandshakeTimeout); } - [Fact] - public void AllowAnyRemoteCertificate_ReplacesExistingValidatorAndAcceptsEverySslPolicyError() - { - using var certificate = TestCertificateHelper.CreateSelfSignedCertificate("tls-options.test"); - RemoteCertificateValidator rejectingValidator = static (_, _, _) => false; - var options = new TlsOptions - { - RemoteCertificateValidation = rejectingValidator - }; - - options.AllowAnyRemoteCertificate(); - - var validator = Assert.IsType(options.RemoteCertificateValidation); - Assert.NotSame(rejectingValidator, validator); - Assert.True(validator(certificate, null, SslPolicyErrors.None)); - Assert.True(validator(certificate, null, SslPolicyErrors.RemoteCertificateNotAvailable)); - Assert.True(validator(certificate, null, SslPolicyErrors.RemoteCertificateNameMismatch)); - Assert.True(validator(certificate, null, SslPolicyErrors.RemoteCertificateChainErrors)); - Assert.True( - validator( - certificate, - null, - SslPolicyErrors.RemoteCertificateNotAvailable - | SslPolicyErrors.RemoteCertificateNameMismatch - | SslPolicyErrors.RemoteCertificateChainErrors)); - } - - [Fact] - public void ClientAuthenticationConfiguration_PropagatesMutationsToSslClientAuthenticationOptions() - { - var connection = new DefaultConnectionContext(); - var authenticationOptions = new TlsClientAuthenticationOptions(); - var underlyingOptions = Assert.IsType( - authenticationOptions.SslClientAuthenticationOptions); - ConnectionContext? receivedConnection = null; - TlsClientAuthenticationOptions? receivedOptions = null; - Action configure = (context, options) => - { - receivedConnection = context; - receivedOptions = options; - options.TargetHost = "silo.tls-options.test"; - options.EnabledSslProtocols = SslProtocols.Tls12; - options.CertificateRevocationCheckMode = X509RevocationMode.Online; - }; - var options = new TlsOptions - { - OnAuthenticateAsClient = configure - }; - - options.OnAuthenticateAsClient(connection, authenticationOptions); - - Assert.Same(configure, options.OnAuthenticateAsClient); - Assert.Same(connection, receivedConnection); - Assert.Same(authenticationOptions, receivedOptions); - Assert.Same(underlyingOptions, authenticationOptions.SslClientAuthenticationOptions); - Assert.Equal("silo.tls-options.test", underlyingOptions.TargetHost); - Assert.Equal(SslProtocols.Tls12, underlyingOptions.EnabledSslProtocols); - Assert.Equal(X509RevocationMode.Online, underlyingOptions.CertificateRevocationCheckMode); - } - - [Fact] - public void ServerAuthenticationConfiguration_PropagatesMutationsToSslServerAuthenticationOptions() + private sealed class TestSiloBuilder(IServiceCollection services) : ISiloBuilder { - var connection = new DefaultConnectionContext(); - var authenticationOptions = new TlsServerAuthenticationOptions(); - var underlyingOptions = Assert.IsType( - authenticationOptions.SslServerAuthenticationOptions); - ConnectionContext? receivedConnection = null; - TlsServerAuthenticationOptions? receivedOptions = null; - Action configure = (context, options) => - { - receivedConnection = context; - receivedOptions = options; - options.ClientCertificateRequired = true; - options.EnabledSslProtocols = SslProtocols.Tls13; - options.CertificateRevocationCheckMode = X509RevocationMode.Offline; - }; - var options = new TlsOptions - { - OnAuthenticateAsServer = configure - }; - - options.OnAuthenticateAsServer(connection, authenticationOptions); - - Assert.Same(configure, options.OnAuthenticateAsServer); - Assert.Same(connection, receivedConnection); - Assert.Same(authenticationOptions, receivedOptions); - Assert.Same(underlyingOptions, authenticationOptions.SslServerAuthenticationOptions); - Assert.True(underlyingOptions.ClientCertificateRequired); - Assert.Equal(SslProtocols.Tls13, underlyingOptions.EnabledSslProtocols); - Assert.Equal(X509RevocationMode.Offline, underlyingOptions.CertificateRevocationCheckMode); + public IServiceCollection Services { get; } = services; + public IConfiguration Configuration { get; } = new ConfigurationBuilder().Build(); } } diff --git a/test/Orleans.Core.Tests/Membership/ClusterHealthMonitorTests.cs b/test/Orleans.Core.Tests/Membership/ClusterHealthMonitorTests.cs index cceb27d10cb..d6558a12992 100644 --- a/test/Orleans.Core.Tests/Membership/ClusterHealthMonitorTests.cs +++ b/test/Orleans.Core.Tests/Membership/ClusterHealthMonitorTests.cs @@ -1,13 +1,14 @@ using System.Collections.Concurrent; using System.Collections.Immutable; using System.Threading.Channels; -using Microsoft.AspNetCore.Connections; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using NonSilo.Tests.Utilities; using NSubstitute; +using Orleans.Connections; +using Orleans.Connections.Transport; using Orleans.Configuration; using Orleans.Core.Diagnostics; using Orleans.Messaging; @@ -944,17 +945,15 @@ private ClusterHealthMonitorTestRig CreateClusterHealthMonitorTestRig(ClusterMem /// private TestConnection CreateTestConnection(ILoggerFactory loggerFactory) { - var features = new Microsoft.AspNetCore.Http.Features.FeatureCollection(); - var context = Substitute.For(); - context.Features.Returns(features); - ConnectionDelegate middleware = _ => Task.CompletedTask; + var transport = Substitute.For(); + transport.Features.Returns(new FeatureCollection()); var services = new ServiceCollection(); services.AddMetrics(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); var serviceProvider = services.BuildServiceProvider(); - var orleansInstruments = serviceProvider.GetRequiredService(); var messagingInstruments = serviceProvider.GetRequiredService(); var messagingTrace = new MessagingTrace( loggerFactory, @@ -964,23 +963,23 @@ private TestConnection CreateTestConnection(ILoggerFactory loggerFactory) serviceProvider, null!, messagingTrace, - orleansInstruments, + new ConnectionTrace(loggerFactory), messagingInstruments, - loggerFactory.CreateLogger(), + serviceProvider.GetRequiredService(), new NoOpMessageStatisticsSink()); - return new TestConnection(context, middleware, shared); + return new TestConnection(transport, shared); } - private sealed class TestConnection(ConnectionContext context, ConnectionDelegate middleware, ConnectionCommon shared) - : Connection(context, middleware, shared) + private sealed class TestConnection(MessageTransport transport, ConnectionCommon shared) + : Connection(transport, shared) { protected override ConnectionDirection ConnectionDirection => ConnectionDirection.SiloToSilo; + protected override TimeSpan CloseConnectionTimeout => TimeSpan.FromSeconds(1); protected override IMessageCenter MessageCenter => null!; protected override bool PrepareMessageForSend(Message msg) => true; - protected override void OnReceivedMessage(Message msg) { } - protected override void RecordMessageReceive(Message msg, int numTotalBytes, int headerBytes) { } - protected override void RecordMessageSend(Message msg, int numTotalBytes, int headerBytes) { } - protected override void OnSendMessageFailure(Message message, string error) { } + protected internal override void OnReceivedMessage(Message msg) { } + protected internal override void RecordMessageReceive(Message message, int totalBytes, int headerBytes) { } + protected internal override void RecordMessageSend(Message message, int totalBytes, int headerBytes) { } protected override void RetryMessage(Message msg, Exception? ex = null) { } public void SimulateMessageReceived() => MarkMessageReceived(); } diff --git a/test/Orleans.Core.Tests/Networking/ConnectionFactoryTests.cs b/test/Orleans.Core.Tests/Networking/ConnectionFactoryTests.cs new file mode 100644 index 00000000000..756b61841be --- /dev/null +++ b/test/Orleans.Core.Tests/Networking/ConnectionFactoryTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Orleans.Connections.Transport; +using Orleans.Runtime; +using Orleans.Runtime.Messaging; +using TestExtensions; +using Xunit; + +namespace Orleans.Core.Tests.Networking; + +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Runtime")] +[TestCategory("BVT")] +public class ConnectionFactoryTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProviderDisposal_DisposesDecoratorChainAndRegisteredConnectorOnce(bool asynchronous) + { + var events = new List(); + var services = new ServiceCollection(); + services.AddSingleton(_ => new TrackingConnector(events)); + services.AddSingleton(_ => new TrackingMiddleware("first", events)); + services.AddSingleton(_ => new TrackingMiddleware("second", events)); + services.AddSingleton(); + var provider = services.BuildServiceProvider(); + var factory = provider.GetRequiredService(); + var connector = provider.GetRequiredService(); + + if (asynchronous) + { + await provider.DisposeAsync(); + } + else + { + provider.Dispose(); + } + + await factory.DisposeAsync(); + factory.Dispose(); + Assert.Equal(["second", "first", "registered"], events); + Assert.Equal(1, Assert.IsType(connector).DisposeCount); + } + + [Fact] + public async Task Disposal_DrainsAdmittedAttemptAndRejectsNewAttempts() + { + var events = new List(); + var connector = new TrackingConnector(events); + var factory = new TestConnectionFactory(connector, [new TrackingMiddleware("decorator", events)]); + var address = SiloAddress.New(IPAddress.Loopback, 12345, 1); + var attempt = factory.ConnectAsync(address, TestContext.Current.CancellationToken).AsTask(); + Assert.Equal(1, connector.AttemptCount); + Assert.Equal(address.Endpoint, connector.Endpoint); + Assert.Equal(TestContext.Current.CancellationToken, connector.CancellationToken); + var disposal = factory.DisposeAsync().AsTask(); + var repeatedDisposal = factory.DisposeAsync().AsTask(); + Assert.Same(disposal, repeatedDisposal); + Assert.False(disposal.IsCompleted); + Assert.Empty(events); + + await Assert.ThrowsAsync( + () => factory.ConnectAsync(address, TestContext.Current.CancellationToken).AsTask()); + Assert.Equal(1, connector.AttemptCount); + var failure = new InvalidOperationException("Connect failed"); + connector.Completion.SetException(failure); + Assert.Same(failure, await Assert.ThrowsAsync(() => attempt)); + await disposal.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + Assert.Equal(["decorator"], events); + Assert.Equal(0, connector.DisposeCount); + await connector.DisposeAsync(); + Assert.Equal(["decorator", "registered"], events); + } + + private sealed class TestConnectionFactory( + MessageTransportConnector connector, + IEnumerable middleware) : ConnectionFactory(connector, middleware) + { + protected override Connection CreateConnection(SiloAddress address, MessageTransport context) => + throw new InvalidOperationException("This test only creates failed connection attempts."); + + protected override EndPoint GetEndPoint(SiloAddress address) => address.Endpoint; + } + + private sealed class TrackingConnector(List events) : MessageTransportConnector, IDisposable + { + public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int AttemptCount { get; private set; } + public int DisposeCount { get; private set; } + public EndPoint? Endpoint { get; private set; } + public CancellationToken CancellationToken { get; private set; } + public override IFeatureCollection Features { get; } = new FeatureCollection(); + public override bool IsValid => true; + + public override ValueTask CreateAsync(EndPoint endpoint, CancellationToken cancellationToken = default) + { + AttemptCount++; + Endpoint = endpoint; + CancellationToken = cancellationToken; + return new(Completion.Task); + } + + public override ValueTask DisposeAsync() + { + DisposeCount++; + events.Add("registered"); + return base.DisposeAsync(); + } + + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + private sealed class TrackingMiddleware(string name, List events) : IMessageTransportConnectorMiddleware + { + public MessageTransportConnector Apply(MessageTransportConnector transport) => new TrackingDecorator(transport, name, events); + } + + private sealed class TrackingDecorator( + MessageTransportConnector inner, + string name, + List events) : MessageTransportConnector + { + public override IFeatureCollection Features => inner.Features; + public override bool IsValid => inner.IsValid; + public override ValueTask CreateAsync(EndPoint endpoint, CancellationToken cancellationToken = default) => + inner.CreateAsync(endpoint, cancellationToken); + + public override async ValueTask DisposeAsync() + { + events.Add(name); + await inner.DisposeAsync(); + await base.DisposeAsync(); + } + } +} diff --git a/test/Orleans.Core.Tests/Networking/ConnectionFrameHelperTests.cs b/test/Orleans.Core.Tests/Networking/ConnectionFrameHelperTests.cs deleted file mode 100644 index 3d59907c0ab..00000000000 --- a/test/Orleans.Core.Tests/Networking/ConnectionFrameHelperTests.cs +++ /dev/null @@ -1,311 +0,0 @@ -using System; -using System.Buffers; -using System.Buffers.Binary; -using System.IO.Pipelines; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.AspNetCore.Http.Features; -using Orleans.Runtime.Messaging; -using Xunit; - -namespace Orleans.Core.Tests.Networking -{ - [Trait("Category", "BVT")] - [TestSuite("BVT")] - [TestProvider("None")] - [TestArea("Runtime")] - public class ConnectionFrameHelperTests - { - [Fact] - public async Task WriteAndReadFrame_RoundTrips() - { - var pipe = new Pipe(); - var context = new TestConnectionContext(pipe); - - byte expectedFrameType = 0x01; - byte[] expectedPayload = Encoding.UTF8.GetBytes("hello world"); - - await ConnectionFrameHelper.WriteFrameAsync(context, expectedFrameType, expectedPayload, CancellationToken.None); - await pipe.Writer.CompleteAsync(); - - var (frameType, payload) = await ConnectionFrameHelper.ReadFrameAsync(context, CancellationToken.None); - - Assert.Equal(expectedFrameType, frameType); - Assert.Equal(expectedPayload, payload); - } - - [Fact] - public async Task WriteAndReadFrame_EmptyPayload() - { - var pipe = new Pipe(); - var context = new TestConnectionContext(pipe); - - byte expectedFrameType = 0x05; - byte[] expectedPayload = Array.Empty(); - - await ConnectionFrameHelper.WriteFrameAsync(context, expectedFrameType, expectedPayload, CancellationToken.None); - await pipe.Writer.CompleteAsync(); - - var (frameType, payload) = await ConnectionFrameHelper.ReadFrameAsync(context, CancellationToken.None); - - Assert.Equal(expectedFrameType, frameType); - Assert.Empty(payload); - } - - [Fact] - public async Task WriteAndReadFrame_ZeroCopyPath_RoundTrips() - { - var pipe = new Pipe(); - var context = new TestConnectionContext(pipe); - - byte expectedFrameType = 0x02; - var expectedText = "zero-copy test"; - - await ConnectionFrameHelper.WriteFrameAsync( - context, - expectedFrameType, - writer => { writer.Write(Encoding.UTF8.GetBytes(expectedText)); }, - CancellationToken.None); - - await pipe.Writer.CompleteAsync(); - - var (frameType, payload) = await ConnectionFrameHelper.ReadFrameAsync(context, CancellationToken.None); - - Assert.Equal(expectedFrameType, frameType); - Assert.Equal(expectedText, Encoding.UTF8.GetString(payload)); - } - - [Fact] - public async Task WriteFrame_CompletedReader_Throws() - { - var pipe = new Pipe(); - var context = new TestConnectionContext(pipe); - await pipe.Reader.CompleteAsync(); - - await Assert.ThrowsAsync(async () => - await ConnectionFrameHelper.WriteFrameAsync(context, 0x01, Array.Empty(), CancellationToken.None)); - } - - [Fact] - public async Task WriteFrame_ZeroCopyPath_CompletedReader_Throws() - { - var pipe = new Pipe(); - var context = new TestConnectionContext(pipe); - await pipe.Reader.CompleteAsync(); - - await Assert.ThrowsAsync(async () => - await ConnectionFrameHelper.WriteFrameAsync(context, 0x01, _ => { }, CancellationToken.None)); - } - - [Fact] - public async Task ReadFrame_RejectsFrameExceedingMaxLength() - { - var pipe = new Pipe(); - var context = new TestConnectionContext(pipe); - - var output = pipe.Writer; - var lengthBytes = new byte[4]; - System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, 2_000_000); - output.Write(lengthBytes); - await output.FlushAsync(TestContext.Current.CancellationToken); - await output.CompleteAsync(); - - await Assert.ThrowsAsync(async () => - await ConnectionFrameHelper.ReadFrameAsync(context, CancellationToken.None, maxFrameLength: 1024)); - } - - [Fact] - public async Task ReadFrame_RejectsInvalidFrameLength() - { - var pipe = new Pipe(); - var context = new TestConnectionContext(pipe); - - var output = pipe.Writer; - var lengthBytes = new byte[4]; - System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, 0); - output.Write(lengthBytes); - await output.FlushAsync(TestContext.Current.CancellationToken); - await output.CompleteAsync(); - - await Assert.ThrowsAsync(async () => - await ConnectionFrameHelper.ReadFrameAsync(context, CancellationToken.None)); - } - - [Fact] - public async Task WriteAndReadMultipleFrames() - { - var pipe = new Pipe(); - var context = new TestConnectionContext(pipe); - - await ConnectionFrameHelper.WriteFrameAsync(context, 0x01, Encoding.UTF8.GetBytes("first"), CancellationToken.None); - await ConnectionFrameHelper.WriteFrameAsync(context, 0x02, Encoding.UTF8.GetBytes("second"), CancellationToken.None); - await ConnectionFrameHelper.WriteFrameAsync(context, 0x03, Encoding.UTF8.GetBytes("third"), CancellationToken.None); - await pipe.Writer.CompleteAsync(); - - var (type1, payload1) = await ConnectionFrameHelper.ReadFrameAsync(context, CancellationToken.None); - var (type2, payload2) = await ConnectionFrameHelper.ReadFrameAsync(context, CancellationToken.None); - var (type3, payload3) = await ConnectionFrameHelper.ReadFrameAsync(context, CancellationToken.None); - - Assert.Equal(0x01, type1); - Assert.Equal("first", Encoding.UTF8.GetString(payload1)); - Assert.Equal(0x02, type2); - Assert.Equal("second", Encoding.UTF8.GetString(payload2)); - Assert.Equal(0x03, type3); - Assert.Equal("third", Encoding.UTF8.GetString(payload3)); - } - - [Fact] - public async Task ReadFrame_ConsumesDataUnderBackpressure() - { - var pipe = new Pipe(new PipeOptions( - pauseWriterThreshold: 64, - resumeWriterThreshold: 32, - minimumSegmentSize: 16, - useSynchronizationContext: false)); - var context = new TestConnectionContext(pipe); - var expectedPayload = new byte[256]; - Array.Fill(expectedPayload, (byte)0x2A); - - var writeTask = WriteFrameInChunksAsync(pipe.Writer, 0x04, expectedPayload, 16); - var readTask = ConnectionFrameHelper.ReadFrameAsync(context, CancellationToken.None).AsTask(); - - await Task.WhenAll(writeTask, readTask).WaitAsync( - TimeSpan.FromSeconds(10), - TestContext.Current.CancellationToken); - - var (frameType, payload) = await readTask; - Assert.Equal(0x04, frameType); - Assert.Equal(expectedPayload, payload); - } - - [Fact] - public void WriteLengthPrefixedString_ReadLengthPrefixedString_RoundTrips() - { - var buffer = new ArrayBufferWriter(); - var expected = "hello, orleans!"; - - ConnectionFrameHelper.WriteLengthPrefixedString(buffer, expected); - - var data = buffer.WrittenSpan.ToArray(); - int offset = 0; - var result = ConnectionFrameHelper.ReadLengthPrefixedString(data, ref offset); - - Assert.Equal(expected, result); - Assert.Equal(data.Length, offset); - } - - [Fact] - public void WriteLengthPrefixedString_EmptyString() - { - var buffer = new ArrayBufferWriter(); - - ConnectionFrameHelper.WriteLengthPrefixedString(buffer, ""); - - var data = buffer.WrittenSpan.ToArray(); - int offset = 0; - var result = ConnectionFrameHelper.ReadLengthPrefixedString(data, ref offset); - - Assert.Equal("", result); - Assert.Equal(4, offset); - } - - [Fact] - public void WriteLengthPrefixedString_MultipleStrings() - { - var buffer = new ArrayBufferWriter(); - var strings = new[] { "alpha", "beta", "gamma" }; - - foreach (var s in strings) - ConnectionFrameHelper.WriteLengthPrefixedString(buffer, s); - - var data = buffer.WrittenSpan.ToArray(); - int offset = 0; - foreach (var expected in strings) - { - var result = ConnectionFrameHelper.ReadLengthPrefixedString(data, ref offset); - Assert.Equal(expected, result); - } - } - - [Fact] - public void ReadLengthPrefixedString_InsufficientData_Throws() - { - var data = new byte[] { 0x01, 0x02 }; - int offset = 0; - - Assert.Throws(() => - ConnectionFrameHelper.ReadLengthPrefixedString(data, ref offset)); - } - - [Fact] - public void ReadLengthPrefixedString_OversizedLength_Throws() - { - var data = new byte[sizeof(int)]; - BinaryPrimitives.WriteInt32LittleEndian(data, int.MaxValue); - int offset = 0; - - Assert.Throws(() => - ConnectionFrameHelper.ReadLengthPrefixedString(data, ref offset)); - } - - [Fact] - public void ReadLengthPrefixedString_NegativeOffset_Throws() - { - var data = Array.Empty(); - int offset = -1; - - Assert.Throws(() => - ConnectionFrameHelper.ReadLengthPrefixedString(data, ref offset)); - } - - private static async Task WriteFrameInChunksAsync(PipeWriter writer, byte frameType, byte[] payload, int chunkSize) - { - var frame = new byte[ConnectionFrameHelper.FramePrefixSize + payload.Length]; - BinaryPrimitives.WriteInt32LittleEndian(frame, payload.Length + 1); - frame[4] = frameType; - payload.CopyTo(frame, ConnectionFrameHelper.FramePrefixSize); - - for (var offset = 0; offset < frame.Length; offset += chunkSize) - { - var length = Math.Min(chunkSize, frame.Length - offset); - writer.Write(frame.AsSpan(offset, length)); - await writer.FlushAsync(); - } - - await writer.CompleteAsync(); - } - - /// - /// Minimal ConnectionContext backed by a Pipe for testing. - /// - private sealed class TestConnectionContext : ConnectionContext - { - private readonly IDuplexPipe _transport; - - public TestConnectionContext(Pipe pipe) - { - _transport = new DuplexPipe(pipe.Reader, pipe.Writer); - } - - public override string ConnectionId { get; set; } = Guid.NewGuid().ToString(); - public override IDuplexPipe Transport { get => _transport; set => throw new NotSupportedException(); } - public override IFeatureCollection Features { get; } = new FeatureCollection(); - public override IDictionary Items { get; set; } = new Dictionary(); - - private sealed class DuplexPipe : IDuplexPipe - { - public DuplexPipe(PipeReader input, PipeWriter output) - { - Input = input; - Output = output; - } - - public PipeReader Input { get; } - public PipeWriter Output { get; } - } - - } - } -} diff --git a/test/Orleans.Core.Tests/Networking/ConnectionManagerTests.cs b/test/Orleans.Core.Tests/Networking/ConnectionManagerTests.cs index f8c7cfa5eac..b54cc16455c 100644 --- a/test/Orleans.Core.Tests/Networking/ConnectionManagerTests.cs +++ b/test/Orleans.Core.Tests/Networking/ConnectionManagerTests.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.IO.Pipelines; using System.Net; using System.Reflection; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -14,11 +14,16 @@ using NSubstitute; using Orleans.Configuration; using Orleans.Messaging; -using Orleans.Networking.Shared; +using Orleans.Connections; +using Orleans.Connections.Transport; +using Orleans.Metadata; using Orleans.Placement.Repartitioning; using Orleans.Runtime; +using Orleans.Runtime.GrainDirectory; using Orleans.Runtime.Messaging; using Orleans.Serialization; +using Orleans.Serialization.Buffers; +using Orleans.TestingHost.InMemoryTransport; using TestExtensions; using Xunit; @@ -32,6 +37,75 @@ public class ConnectionManagerTests { private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); + [Theory] + [InlineData((int)Message.Directions.Request)] + [InlineData((int)Message.Directions.Response)] + [InlineData((int)Message.Directions.OneWay)] + public async Task SiloConnection_Shutdown_InvalidatesRequestsAndReleasesBufferedBodies(int direction) + { + await using var rig = new TestRig(); + var responses = new List(); + var sender = rig.CreateConnection(); + sender.SendObserver = responses.Add; + rig.Manager.OnConnected(rig.Address, sender); + var (connection, _) = rig.CreateSiloConnection(blockApplicationMessages: true); + using var body = new ArcBufferWriter(); + body.Write([1, 2, 3]); + var readRequest = rig.CreateReadRequest(); + readRequest.Body = body.ConsumeSlice(body.Length); + using var message = new Message + { + Direction = (Message.Directions)direction, + Id = new CorrelationId(1), + SendingGrain = GrainId.Create("test", "caller"), + TargetGrain = GrainId.Create("test", "target"), + SendingSilo = rig.Address, + TargetSilo = connection.LocalSiloAddress + }; + message.SetMessageReadRequest(readRequest); + + try + { + connection.OnReceivedMessage(message); + + Assert.Null(message._bodyObject); + Assert.Equal(0, readRequest.Body.Length); + if (message.Direction is Message.Directions.Request) + { + var response = Assert.Single(responses); + Assert.Equal(Message.Directions.Response, response.Direction); + Assert.Equal(Message.ResponseTypes.Rejection, response.Result); + Assert.Equal(message.Id, response.Id); + Assert.Equal(message.SendingGrain, response.TargetGrain); + Assert.Equal(rig.Address, response.TargetSilo); + var rejection = Assert.IsType(response.BodyObject); + Assert.Equal(Message.RejectionTypes.Transient, rejection.RejectionType); + Assert.Contains("Silo stopping", rejection.RejectionInfo); + var invalidation = Assert.Single(response.CacheInvalidationHeader!); + Assert.Equal(message.TargetGrain, invalidation.GrainId); + Assert.Equal(connection.LocalSiloAddress, invalidation.InvalidSiloAddress); + Assert.Null(invalidation.ValidGrainAddress); + rig.GrainDirectory.Received(1).InvalidateCacheEntry( + Arg.Is(address => address.GrainId == message.TargetGrain + && address.SiloAddress == connection.LocalSiloAddress)); + } + else + { + Assert.Empty(responses); + } + } + finally + { + foreach (var response in responses) + { + response.Dispose(); + } + + await sender.CloseAsync(exception: null).WaitAsync(TestTimeout, CancellationToken.None); + rig.Manager.OnConnectionTerminated(rig.Address, sender, exception: null); + } + } + [Fact] public async Task Close_WaitsForAdmittedUnpublishedEstablishment() { @@ -100,7 +174,7 @@ public async Task Close_DrainsPublicationFromPreviouslyAdmittedSiloHandshake() await Assert.ThrowsAsync(() => rig.Manager.GetConnection(rejectedAddress).AsTask()); var (connection, context) = rig.CreateSiloConnection(); - await using var peer = new DefaultConnectionContext { Transport = context.PeerTransport }; + var peer = context.PeerTransport; attempt.Completion.SetResult(connection); await rig.PreambleHelper.Write(peer, new ConnectionPreamble { @@ -247,23 +321,23 @@ public async Task OnConnected_RejectsInboundPublicationAfterClose() } [Fact] - public async Task Run_ClosesQueuedConnectionBeforeInvokingMiddleware() + public async Task RunAsync_ClosesQueuedConnectionBeforeStarting() { await using var rig = new TestRig(); var connection = rig.CreateConnection(); await connection.CloseAsync(exception: null).WaitAsync(TestTimeout, TestContext.Current.CancellationToken); - await connection.Run().WaitAsync(TestTimeout, TestContext.Current.CancellationToken); + await connection.RunAsync().WaitAsync(TestTimeout, TestContext.Current.CancellationToken); Assert.False(connection.Started.Task.IsCompleted); Assert.False(connection.IsValid); Assert.True(connection.Initialized.IsFaulted); - Assert.Equal(1, connection.TransportContext.AbortCount); + Assert.Equal(1, connection.TransportContext.CloseCount); Assert.Equal(1, connection.TransportContext.DisposeCount); } [Fact] - public async Task InitializationTimeout_AbortsWrappedTransportBeforeMiddlewareDisposal() + public async Task InitializationTimeout_ClosesWrappedTransportBeforeDisposal() { var middleware = new WrappedTransportMiddleware(); await using var rig = new TestRig(transportMiddleware: middleware, openConnectionTimeout: TimeSpan.FromSeconds(5)); @@ -272,49 +346,47 @@ public async Task InitializationTimeout_AbortsWrappedTransportBeforeMiddlewareDi var connection = rig.CreateConnection(blockInitialization: true); attempt.Completion.SetResult(connection); await connection.Started.Task.WaitAsync(TestTimeout, TestContext.Current.CancellationToken); - await connection.TransportContext.Aborted.Task.WaitAsync(TestTimeout, TestContext.Current.CancellationToken); + await connection.TransportContext.Closing.Task.WaitAsync(TestTimeout, TestContext.Current.CancellationToken); await middleware.Unwinding.Task.WaitAsync(TestTimeout, TestContext.Current.CancellationToken); Assert.True(attempt.CancellationToken.IsCancellationRequested); Assert.False(rig.ShutdownSource.Token.IsCancellationRequested); Assert.False(acquisition.IsCompleted); Assert.False(connection.TransportContext.Disposing.Task.IsCompleted); - Assert.NotSame(connection.TransportContext.OriginalTransport, connection.TransportContext.Transport); + Assert.NotSame(connection.TransportContext, connection.MessageTransport); middleware.ReleaseCleanup.TrySetResult(); var error = await Assert.ThrowsAsync(() => acquisition.WaitAsync(TestTimeout, TestContext.Current.CancellationToken)); Assert.Contains("timed out after", error.Message); Assert.Null(error.InnerException); - Assert.Same(connection.TransportContext.OriginalTransport, connection.TransportContext.DisposedTransport); - Assert.Equal(1, connection.TransportContext.AbortCount); + Assert.Equal(1, connection.TransportContext.CloseCount); Assert.Equal(1, connection.TransportContext.DisposeCount); Assert.Equal(0, rig.Manager.ConnectionCount); } [Fact] - public async Task Run_AbortsTransportAfterSynchronousMiddlewareFailure() + public async Task RunAsync_ClosesTransportAfterSynchronousInitializationFailure() { await using var rig = new TestRig(); - var failure = new InvalidOperationException("Synchronous middleware failure"); - var connection = rig.CreateConnection(middleware: _ => throw failure); + var failure = new InvalidOperationException("Synchronous initialization failure"); + var connection = rig.CreateConnection(initialize: _ => throw failure); - await connection.Run().WaitAsync(TestTimeout, TestContext.Current.CancellationToken); + await connection.RunAsync().WaitAsync(TestTimeout, TestContext.Current.CancellationToken); var error = await Assert.ThrowsAsync(() => connection.Initialized); Assert.Same(failure, error); Assert.False(connection.Started.Task.IsCompleted); - Assert.Equal(1, connection.TransportContext.AbortCount); + Assert.Equal(1, connection.TransportContext.CloseCount); Assert.Equal(1, connection.TransportContext.DisposeCount); } private sealed class TestRig : IAsyncDisposable { private readonly ServiceProvider _services; - private readonly ConcurrentBag<(Connection Connection, TestConnectionContext Context)> _connections = []; - private readonly SharedMemoryPool _memoryPool = new(); + private readonly ConcurrentBag<(Connection Connection, TestMessageTransport Context)> _connections = []; + private readonly List _messageCenters = []; private readonly ConnectionCommon _shared; - private readonly ConnectionDelegate _middleware; private readonly ConnectionOptions _options; private readonly WrappedTransportMiddleware? _transportMiddleware; @@ -330,41 +402,44 @@ public TestRig( services.AddSerializer(builder => builder.AddAssembly(typeof(Connection).Assembly)); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(_memoryPool); services.AddSingleton(new ClientMessagingOptions()); services.AddTransient(); services.AddSingleton(); + services.AddSingleton(sp => new MessagingTrace( + NullLoggerFactory.Instance, + sp.GetRequiredService(), + sp.GetRequiredService())); + services.AddSingleton(new ConnectionTrace(NullLoggerFactory.Instance)); + services.AddSingleton(sp => new MessageFactory( + sp.GetRequiredService(), + NullLogger.Instance, + sp.GetRequiredService())); + services.AddSingleton(sp => new( + sp.GetRequiredService(), + sp.GetRequiredService(), + () => sp.GetRequiredService(), + sp.GetRequiredService(), + Substitute.For(), + sp.GetRequiredService())); _services = services.BuildServiceProvider(); var instruments = _services.GetRequiredService(); _shared = new ConnectionCommon( _services, - null!, - new MessagingTrace(NullLoggerFactory.Instance, instruments, _services.GetRequiredService()), - _services.GetRequiredService(), + _services.GetRequiredService(), + _services.GetRequiredService(), + _services.GetRequiredService(), instruments, - NullLogger.Instance, + _services.GetRequiredService(), new NoOpMessageStatisticsSink()); - var builder = new ConnectionBuilder(_services); - builder.Use(next => context => - { - context.Features.Set(new UnderlyingConnectionTransportFeature { Transport = context.Transport }); - return next(context); - }); - if (transportMiddleware is not null) - { - builder.Use(next => context => transportMiddleware.Invoke(context, next)); - } - - Connection.ConfigureBuilder(builder); - _middleware = builder.Build(); _options = new ConnectionOptions { OpenConnectionTimeout = openConnectionTimeout ?? System.Threading.Timeout.InfiniteTimeSpan, ConnectionRetryDelay = TimeSpan.FromMinutes(1) }; var options = Options.Create(_options); - Factory = new TestConnectionFactory(_services, options); + Factory = new TestConnectionFactory(); Manager = new ConnectionManager(options, Factory, logger ?? NullLogger.Instance); ShutdownSource = (CancellationTokenSource)typeof(ConnectionManager) .GetField("shutdownCancellation", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(Manager)!; @@ -372,6 +447,7 @@ public TestRig( public SiloAddress Address { get; } = SiloAddress.New(new IPEndPoint(IPAddress.Loopback, 12345), 1); public ConnectionManager Manager { get; } + public ILocalGrainDirectory GrainDirectory { get; } = Substitute.For(); public TestConnectionFactory Factory { get; } public CancellationTokenSource ShutdownSource { get; } public ConnectionPreambleHelper PreambleHelper => _services.GetRequiredService(); @@ -380,21 +456,58 @@ public TestConnection CreateConnection( bool blockInitialization = false, bool blockCleanup = false, bool blockDisposal = false, - ConnectionDelegate? middleware = null) + Func? initialize = null) { - var context = new TestConnectionContext(blockDisposal); - var connection = new TestConnection(context, middleware ?? _middleware, _shared, Manager, Address, blockInitialization, blockCleanup); + var context = new TestMessageTransport(blockDisposal); + var transport = _transportMiddleware?.Wrap(context) ?? (MessageTransport)context; + var connection = new TestConnection(context, transport, _shared, Manager, Address, PreambleHelper, blockInitialization, blockCleanup, initialize); _connections.Add((connection, context)); return connection; } - public (SiloConnection Connection, TestConnectionContext Context) CreateSiloConnection() + public MessageReadRequest CreateReadRequest() => _shared.MessageHandlerShared.GetReceiveMessageHandler(); + + public (SiloConnection Connection, TestMessageTransport Context) CreateSiloConnection(bool blockApplicationMessages = false) { - var context = new TestConnectionContext(blockDisposal: false); + var context = new TestMessageTransport(blockDisposal: false); var local = Substitute.For(); local.SiloAddress.Returns(SiloAddress.New(new IPEndPoint(IPAddress.Loopback, 12344), 1)); local.ClusterId.Returns("test-cluster"); - var connection = new SiloConnection(Address, context, _middleware, null!, local, Manager, _options, _shared, null!, PreambleHelper); + MessageCenter? messageCenter = null; + if (blockApplicationMessages) + { + var directoryResolver = new GrainDirectoryResolver( + _services, + new GrainPropertiesResolver(Substitute.For()), + []); + var locatorResolver = new GrainLocatorResolver( + _services, + directoryResolver, + null!, + new DhtGrainLocator(GrainDirectory, null!)); + messageCenter = new MessageCenter( + local, + _shared.MessageFactory, + null!, + null!, + NullLogger.Instance, + Substitute.For(), + Manager, + new RuntimeMessagingTrace( + NullLoggerFactory.Instance, + _services.GetRequiredService(), + _services.GetRequiredService()), + _services.GetRequiredService(), + _services.GetRequiredService(), + Options.Create(new SiloMessagingOptions()), + null!, + new GrainLocator(locatorResolver, null!), + new NoOpMessageStatisticsSink()); + messageCenter.BlockApplicationMessages(); + _messageCenters.Add(messageCenter); + } + + var connection = new SiloConnection(Address, context, messageCenter!, local, Manager, _options, _shared, null!, PreambleHelper); _connections.Add((connection, context)); return (connection, context); } @@ -415,13 +528,16 @@ public async ValueTask DisposeAsync() Factory.FailPendingAttempts(); await Manager.Close(CancellationToken.None).WaitAsync(TestTimeout); + foreach (var messageCenter in _messageCenters) + { + await messageCenter.DisposeAsync(); + } + await _services.DisposeAsync(); - _memoryPool.Pool.Dispose(); } } - private sealed class TestConnectionFactory(IServiceProvider services, IOptions options) - : ConnectionFactory(null!, services, options) + private sealed class TestConnectionFactory() : ConnectionFactory(null!, []) { private readonly Channel _attempts = Channel.CreateUnbounded(); private readonly ConcurrentBag _allAttempts = []; @@ -448,7 +564,8 @@ public void FailPendingAttempts() } } - protected override Connection CreateConnection(SiloAddress address, ConnectionContext context) => throw new NotSupportedException(); + protected override Connection CreateConnection(SiloAddress address, MessageTransport transport) => throw new NotSupportedException(); + protected override EndPoint GetEndPoint(SiloAddress address) => address.Endpoint; } private sealed class Attempt(CancellationToken cancellationToken) @@ -462,46 +579,59 @@ private sealed class TestConnection : Connection private readonly ConnectionManager _manager; private readonly SiloAddress _address; private readonly bool _blockInitialization; + private readonly ConnectionPreambleHelper _preambleHelper; + private readonly Func? _initialize; public TestConnection( - TestConnectionContext context, - ConnectionDelegate middleware, + TestMessageTransport context, + MessageTransport transport, ConnectionCommon shared, ConnectionManager manager, SiloAddress address, + ConnectionPreambleHelper preambleHelper, bool blockInitialization, - bool blockCleanup) : base(context, middleware, shared) + bool blockCleanup, + Func? initialize) : base(transport, shared) { TransportContext = context; _manager = manager; _address = address; _blockInitialization = blockInitialization; + _preambleHelper = preambleHelper; + _initialize = initialize; if (!blockCleanup) { ReleaseCleanup.SetResult(); } } - public TestConnectionContext TransportContext { get; } + public TestMessageTransport TransportContext { get; } + public MessageTransport MessageTransport => Context; public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource CleanupStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource ReleaseCleanup { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Action? SendObserver { get; set; } protected override ConnectionDirection ConnectionDirection => ConnectionDirection.SiloToSilo; + protected override TimeSpan CloseConnectionTimeout => TestTimeout; protected override IMessageCenter MessageCenter => null!; - protected override async Task RunInternal() + protected override async Task RunAsyncCore() { - Started.SetResult(); try { + if (_initialize is not null) + { + await _initialize(Context); + } + + Started.SetResult(); if (_blockInitialization) { - var read = await Context.Transport.Input.ReadAsync(); - Context.Transport.Input.AdvanceTo(read.Buffer.Start); + await _preambleHelper.Read(Context); throw new ConnectionAbortedException("Initialization read terminated"); } - await base.RunInternal(); + await base.RunAsyncCore(); } finally { @@ -512,27 +642,43 @@ protected override async Task RunInternal() } protected override bool PrepareMessageForSend(Message message) => true; + public override void Send(Message message) + { + if (SendObserver is { } observer) + { + observer(message); + } + else + { + base.Send(message); + } + } + protected override void RetryMessage(Message message, Exception? exception = null) { } - protected override void OnReceivedMessage(Message message) { } - protected override void RecordMessageReceive(Message message, int numTotalBytes, int headerBytes) { } - protected override void RecordMessageSend(Message message, int numTotalBytes, int headerBytes) { } - protected override void OnSendMessageFailure(Message message, string error) { } + protected internal override void OnReceivedMessage(Message message) { } + protected internal override void RecordMessageReceive(Message message, int numTotalBytes, int headerBytes) { } + protected internal override void RecordMessageSend(Message message, int numTotalBytes, int headerBytes) { } } - private sealed class TestConnectionContext : DefaultConnectionContext + private sealed class TestMessageTransport : MessageTransport { - private readonly Pipe _incoming = new(); - private readonly Pipe _outgoing = new(); + private readonly InMemoryMessageTransport _transport; private int _disposeCount; - private int _abortCount; + private int _closeCount; - public TestConnectionContext(bool blockDisposal) : base(Guid.NewGuid().ToString()) + public TestMessageTransport(bool blockDisposal) { - Transport = new DuplexPipe(_incoming.Reader, _outgoing.Writer); - OriginalTransport = Transport; - PeerTransport = new DuplexPipe(_outgoing.Reader, _incoming.Writer); - LocalEndPoint = new IPEndPoint(IPAddress.Loopback, 12344); - RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 12345); + var incoming = new Pipe(); + var outgoing = new Pipe(); + _transport = new InMemoryMessageTransport(new DuplexPipe(incoming.Reader, outgoing.Writer), NullLogger.Instance); + PeerTransport = new InMemoryMessageTransport(new DuplexPipe(outgoing.Reader, incoming.Writer), NullLogger.Instance); + _transport.Start(); + PeerTransport.Start(); + Features.Set(new ConnectionEndPointFeature + { + LocalEndPoint = new IPEndPoint(IPAddress.Loopback, 12344), + RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 12345) + }); if (!blockDisposal) { ReleaseDisposal.SetResult(); @@ -540,29 +686,31 @@ public TestConnectionContext(bool blockDisposal) : base(Guid.NewGuid().ToString( } public int DisposeCount => Volatile.Read(ref _disposeCount); - public int AbortCount => Volatile.Read(ref _abortCount); - public IDuplexPipe OriginalTransport { get; } - public IDuplexPipe PeerTransport { get; } - public IDuplexPipe? DisposedTransport { get; private set; } - public TaskCompletionSource Aborted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int CloseCount => Volatile.Read(ref _closeCount); + public InMemoryMessageTransport PeerTransport { get; } + public override CancellationToken Closed => _transport.Closed; + public override IFeatureCollection Features => _transport.Features; + public TaskCompletionSource Closing { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource Disposing { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource ReleaseDisposal { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public override void Abort(ConnectionAbortedException abortReason) + public override bool EnqueueRead(ReadRequest request) => _transport.EnqueueRead(request); + public override bool EnqueueWrite(WriteRequest request) => _transport.EnqueueWrite(request); + + public override async ValueTask CloseAsync(Exception? closeException, CancellationToken cancellationToken = default) { - Interlocked.Increment(ref _abortCount); - base.Abort(abortReason); - Aborted.TrySetResult(); + Interlocked.Increment(ref _closeCount); + Closing.TrySetResult(); + await _transport.CloseAsync(closeException, cancellationToken); } public override async ValueTask DisposeAsync() { Interlocked.Increment(ref _disposeCount); - DisposedTransport = Transport; Disposing.TrySetResult(); await ReleaseDisposal.Task; - await _incoming.Writer.CompleteAsync(); - await _outgoing.Reader.CompleteAsync(); + await _transport.DisposeAsync(); + await PeerTransport.DisposeAsync(); await base.DisposeAsync(); } @@ -578,35 +726,23 @@ private sealed class WrappedTransportMiddleware public TaskCompletionSource Unwinding { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource ReleaseCleanup { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public async Task Invoke(ConnectionContext context, ConnectionDelegate next) + public MessageTransport Wrap(MessageTransport transport) => new WrappedTransport(transport, this); + + private sealed class WrappedTransport(MessageTransport inner, WrappedTransportMiddleware middleware) : MessageTransport { - var original = context.Transport; - var wrapped = new StreamTransport(original); - context.Transport = wrapped; - try - { - await next(context); - } - finally + public override CancellationToken Closed => inner.Closed; + public override IFeatureCollection Features => inner.Features; + public override bool EnqueueRead(ReadRequest request) => inner.EnqueueRead(request); + public override bool EnqueueWrite(WriteRequest request) => inner.EnqueueWrite(request); + + public override async ValueTask CloseAsync(Exception? closeException, CancellationToken cancellationToken = default) { - Unwinding.TrySetResult(); - await ReleaseCleanup.Task; - try - { - await wrapped.Input.CompleteAsync(); - await wrapped.Output.CompleteAsync(); - } - finally - { - context.Transport = original; - } + await inner.CloseAsync(closeException, cancellationToken); + middleware.Unwinding.TrySetResult(); + await middleware.ReleaseCleanup.Task; } - } - private sealed class StreamTransport(IDuplexPipe underlying) : IDuplexPipe - { - public PipeReader Input { get; } = PipeReader.Create(underlying.Input.AsStream(leaveOpen: true), new StreamPipeReaderOptions(leaveOpen: true)); - public PipeWriter Output { get; } = PipeWriter.Create(underlying.Output.AsStream(leaveOpen: true), new StreamPipeWriterOptions(leaveOpen: true)); + public override ValueTask DisposeAsync() => inner.DisposeAsync(); } } diff --git a/test/Orleans.Core.Tests/Networking/ConnectionMiddlewareTests.cs b/test/Orleans.Core.Tests/Networking/ConnectionMiddlewareTests.cs deleted file mode 100644 index f45e30a2517..00000000000 --- a/test/Orleans.Core.Tests/Networking/ConnectionMiddlewareTests.cs +++ /dev/null @@ -1,210 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO.Pipelines; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Connections; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.Extensions.DependencyInjection; -using Orleans.Runtime.Messaging; -using Xunit; - -namespace Orleans.Core.Tests.Networking -{ - [Trait("Category", "BVT")] - [TestSuite("BVT")] - [TestProvider("None")] - [TestArea("Runtime")] - public class ConnectionMiddlewareTests - { - [Fact] - public async Task UseMiddleware_InvokesMiddlewareAndNext() - { - var callOrder = new List(); - - var services = new ServiceCollection().BuildServiceProvider(); - var builder = new TestConnectionBuilder(services); - - builder.UseMiddleware(new TrackingMiddleware("first", callOrder)); - builder.UseMiddleware(new TrackingMiddleware("second", callOrder)); - - var pipeline = builder.Build(); - var context = CreateContext(); - - await pipeline(context); - - Assert.Equal(new[] { "first", "second" }, callOrder); - } - - [Fact] - public async Task UseMiddleware_MiddlewareThatDoesNotCallNext_TerminatesPipeline() - { - var callOrder = new List(); - - var services = new ServiceCollection().BuildServiceProvider(); - var builder = new TestConnectionBuilder(services); - - builder.UseMiddleware(new TerminatingMiddleware("blocker", callOrder)); - builder.UseMiddleware(new TrackingMiddleware("should-not-run", callOrder)); - - var pipeline = builder.Build(); - var context = CreateContext(); - - await pipeline(context); - - Assert.Equal(new[] { "blocker" }, callOrder); - } - - [Fact] - public async Task UseMiddleware_Generic_ResolvesFromDI() - { - var callOrder = new List(); - var middleware = new InjectedMiddleware(callOrder); - - var services = new ServiceCollection() - .AddSingleton(callOrder) - .AddSingleton(middleware) - .BuildServiceProvider(); - - var builder = new TestConnectionBuilder(services); - builder.UseMiddleware(); - - var pipeline = builder.Build(); - var context = CreateContext(); - - await pipeline(context); - - Assert.Equal(new[] { "injected-middleware" }, callOrder); - Assert.Equal(1, middleware.InvocationCount); - } - - [Fact] - public void UseMiddleware_Generic_RequiresRegistration() - { - var services = new ServiceCollection().BuildServiceProvider(); - var builder = new TestConnectionBuilder(services); - builder.UseMiddleware(); - - Assert.Throws(() => builder.Build()); - } - - private static ConnectionContext CreateContext() - { - var pipe = new Pipe(); - return new TestConnectionContext(pipe); - } - - private sealed class TrackingMiddleware : IConnectionMiddleware - { - private readonly string _name; - private readonly List _callOrder; - - public TrackingMiddleware(string name, List callOrder) - { - _name = name; - _callOrder = callOrder; - } - - public async Task OnConnectionAsync(ConnectionContext context, ConnectionDelegate next) - { - _callOrder.Add(_name); - await next(context); - } - } - - private sealed class TerminatingMiddleware : IConnectionMiddleware - { - private readonly string _name; - private readonly List _callOrder; - - public TerminatingMiddleware(string name, List callOrder) - { - _name = name; - _callOrder = callOrder; - } - - public Task OnConnectionAsync(ConnectionContext context, ConnectionDelegate next) - { - _callOrder.Add(_name); - // Intentionally NOT calling next - return Task.CompletedTask; - } - } - - private sealed class InjectedMiddleware : IConnectionMiddleware - { - private readonly List _callOrder; - - public InjectedMiddleware(List callOrder) - { - _callOrder = callOrder; - } - - public int InvocationCount { get; private set; } - - public async Task OnConnectionAsync(ConnectionContext context, ConnectionDelegate next) - { - InvocationCount++; - _callOrder.Add("injected-middleware"); - await next(context); - } - } - - private sealed class TestConnectionBuilder : IConnectionBuilder - { - private readonly List> _middlewares = new(); - - public TestConnectionBuilder(IServiceProvider services) - { - ApplicationServices = services; - } - - public IServiceProvider ApplicationServices { get; } - - public IConnectionBuilder Use(Func middleware) - { - _middlewares.Add(middleware); - return this; - } - - public ConnectionDelegate Build() - { - ConnectionDelegate app = _ => Task.CompletedTask; - - for (var i = _middlewares.Count - 1; i >= 0; i--) - { - app = _middlewares[i](app); - } - - return app; - } - } - - private sealed class TestConnectionContext : ConnectionContext - { - private readonly IDuplexPipe _transport; - - public TestConnectionContext(Pipe pipe) - { - _transport = new DuplexPipe(pipe.Reader, pipe.Writer); - } - - public override string ConnectionId { get; set; } = Guid.NewGuid().ToString(); - public override IDuplexPipe Transport { get => _transport; set => throw new NotSupportedException(); } - public override IFeatureCollection Features { get; } = new FeatureCollection(); - public override IDictionary Items { get; set; } = new Dictionary(); - - private sealed class DuplexPipe : IDuplexPipe - { - public DuplexPipe(PipeReader input, PipeWriter output) - { - Input = input; - Output = output; - } - - public PipeReader Input { get; } - public PipeWriter Output { get; } - } - - } - } -} diff --git a/test/Orleans.Core.Tests/Networking/MessageTransportLifecycleTests.cs b/test/Orleans.Core.Tests/Networking/MessageTransportLifecycleTests.cs new file mode 100644 index 00000000000..7bf6e56aaed --- /dev/null +++ b/test/Orleans.Core.Tests/Networking/MessageTransportLifecycleTests.cs @@ -0,0 +1,1023 @@ +using System; +using System.Buffers; +using System.Linq; +using System.Reflection; +using System.Net; +using System.Net.Sockets; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Orleans.Configuration; +using Orleans.Connections; +using Orleans.Connections.Transport; +using Orleans.Connections.Transport.Streams; +using Orleans.Connections.Transport.Sockets; +using Orleans.Connections.Transport.Security; +using Orleans.Messaging; +using Orleans.Placement.Repartitioning; +using Orleans.Runtime; +using Orleans.Runtime.Messaging; +using Orleans.Serialization; +using Orleans.Serialization.Buffers; +using Orleans.Serialization.Cloning; +using Orleans.Serialization.Session; +using TestExtensions; +using Xunit; +using SslApplicationProtocol = System.Net.Security.SslApplicationProtocol; +using SslClientAuthenticationOptions = System.Net.Security.SslClientAuthenticationOptions; +using SslStream = System.Net.Security.SslStream; + +namespace Orleans.Core.Tests.Networking; + +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Runtime")] +[TestCategory("BVT")] +public class MessageTransportLifecycleTests +{ + [Fact] + public void ConnectionOptions_CloseConnectionTimeout_HasCorrectDefault() + { + var options = new ConnectionOptions(); + Assert.Equal(TimeSpan.FromSeconds(30), options.CloseConnectionTimeout); + } + + [Fact] + public void ConnectionOptions_CloseConnectionTimeout_CanBeModified() + { + var options = new ConnectionOptions(); + var customTimeout = TimeSpan.FromSeconds(60); + + options.CloseConnectionTimeout = customTimeout; + + Assert.Equal(customTimeout, options.CloseConnectionTimeout); + } + + [Fact] + public void ConnectionOptions_CloseConnectionTimeout_CanBeSetToShortValue() + { + var options = new ConnectionOptions(); + var shortTimeout = TimeSpan.FromMilliseconds(100); + + options.CloseConnectionTimeout = shortTimeout; + + Assert.Equal(shortTimeout, options.CloseConnectionTimeout); + } + + [Fact] + public void ConnectionClosedException_HasProperMessage() + { + var message = "Test close reason"; + var exception = new ConnectionClosedException(message); + + Assert.Equal(message, exception.Message); + } + + [Fact] + public void ConnectionClosedException_PreservesInnerException() + { + var innerException = new InvalidOperationException("Inner error"); + var exception = new ConnectionClosedException("Outer", innerException); + + Assert.Equal(innerException, exception.InnerException); + } + + [Fact] + public void ConnectionAbortedException_HasProperMessage() + { + var message = "Test abort reason"; + var exception = new ConnectionAbortedException(message); + + Assert.Equal(message, exception.Message); + } + + [Fact] + public void ConnectionAbortedException_PreservesInnerException() + { + var innerException = new InvalidOperationException("Inner error"); + var exception = new ConnectionAbortedException("Outer", innerException); + + Assert.Equal(innerException, exception.InnerException); + } + + [Fact] + public void ConnectionOptions_DEFAULT_CLOSECONNECTION_TIMEOUT_HasCorrectValue() + { + Assert.Equal(TimeSpan.FromSeconds(30), ConnectionOptions.DEFAULT_CLOSECONNECTION_TIMEOUT); + } + + [Fact] + public void MessageSerializer_Write_PreservesBufferedRawResponseForRetry() + { + using var serviceProvider = CreateServiceProvider(); + var sessionPool = serviceProvider.GetRequiredService(); + var serializer = new MessageSerializer(sessionPool, new SiloMessagingOptions()); + var shared = CreateMessageHandlerShared(serviceProvider); + using var bodyWriter = new ArcBufferWriter(); + byte[] bodyBytes = [1, 2, 3, 4]; + bodyWriter.Write(bodyBytes); + + var readRequest = new MessageReadRequest(shared); + readRequest._originalResponseType = Message.ResponseTypes.Success; + readRequest.Body = bodyWriter.ConsumeSlice(bodyBytes.Length); + typeof(MessageReadRequest).GetField("_bodyLength", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(readRequest, bodyBytes.Length); + + var message = new Message + { + Direction = Message.Directions.Response, + Result = Message.ResponseTypes.Success, + BodyObject = readRequest + }; + + using var firstOutput = new ArcBufferWriter(); + using var secondOutput = new ArcBufferWriter(); + var firstLengths = serializer.Write(firstOutput, message); + var secondLengths = serializer.Write(secondOutput, message); + + Assert.Equal(bodyBytes.Length, firstLengths.BodyLength); + Assert.Equal(firstLengths, secondLengths); + var firstBytes = new byte[firstOutput.Length]; + var secondBytes = new byte[secondOutput.Length]; + firstOutput.Peek(firstBytes); + secondOutput.Peek(secondBytes); + Assert.Equal(firstBytes, secondBytes); + Assert.Equal(bodyBytes, firstBytes[firstLengths.HeaderLength..(firstLengths.HeaderLength + firstLengths.BodyLength)]); + Assert.Same(readRequest, message._bodyObject); + message.Dispose(); + } + + [Fact] + public void MessageSerializer_ReadCacheInvalidationHeaders_ConsumesAllEntriesAndRetainsLimit() + { + using var serviceProvider = CreateServiceProvider(); + var serializer = new MessageSerializer(serviceProvider.GetRequiredService(), new SiloMessagingOptions()); + var entries = Enumerable.Range(0, Message.MaxCacheInvalidationHeaderEntries + 2) + .Select(static i => + { + var grainId = GrainId.Create("test", i.ToString()); + return new GrainAddressCacheUpdate( + new GrainAddress + { + GrainId = grainId, + ActivationId = ActivationId.NewId(), + SiloAddress = SiloAddress.New(IPAddress.Loopback, 10_000 + i, i + 1) + }, + validAddress: null); + }) + .ToList(); + var message = new Message { CacheInvalidationHeader = entries }; + using var buffer = new ArcBufferWriter(); + var (headerLength, _) = serializer.Write(buffer, message); + var shared = CreateMessageHandlerShared(serviceProvider); + var readRequest = new MessageReadRequest(shared); + readRequest.Headers = buffer.ConsumeSlice(headerLength); + typeof(MessageReadRequest).GetField("_headerLength", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(readRequest, headerLength); + + serializer.ReadHeaders(readRequest, out var deserialized); + + var invalidationHeader = Assert.IsType>(deserialized.CacheInvalidationHeader); + Assert.Equal(Message.MaxCacheInvalidationHeaderEntries, invalidationHeader.Count); + Assert.Equal(entries.Take(Message.MaxCacheInvalidationHeaderEntries).Select(static entry => entry.GrainId), + invalidationHeader.Select(static entry => entry.GrainId)); + readRequest.Reset(); + } + + [Fact] + public void MessageFactory_ResponseTimeToLive_IsSerialized() + { + using var serviceProvider = CreateServiceProvider(); + var serializer = new MessageSerializer(serviceProvider.GetRequiredService(), new SiloMessagingOptions()); + var shared = CreateMessageHandlerShared(serviceProvider); + var factory = new MessageFactory( + serviceProvider.GetRequiredService(), + NullLogger.Instance, + shared.MessagingTrace); + var request = new Message { TimeToLive = TimeSpan.FromMinutes(1) }; + var response = factory.CreateResponseMessage(request); + using var buffer = new ArcBufferWriter(); + var (headerLength, _) = serializer.Write(buffer, response); + var readRequest = new MessageReadRequest(shared); + readRequest.Headers = buffer.ConsumeSlice(headerLength); + typeof(MessageReadRequest).GetField("_headerLength", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(readRequest, headerLength); + + serializer.ReadHeaders(readRequest, out var deserialized); + + Assert.NotNull(deserialized.TimeToLive); + readRequest.Reset(); + } + + [Fact] + public void Message_DeserializeRequestBodyFailure_ReturnsReadRequestOnce() + { + using var serviceProvider = CreateServiceProvider(); + var shared = CreateMessageHandlerShared(serviceProvider); + using var bodyWriter = new ArcBufferWriter(); + bodyWriter.Write([0xff]); + var readRequest = new MessageReadRequest(shared); + readRequest.Body = bodyWriter.ConsumeSlice(1); + typeof(MessageReadRequest).GetField("_bodyLength", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(readRequest, 1); + var message = new Message { Direction = Message.Directions.Request }; + message.SetMessageReadRequest(readRequest); + + Assert.ThrowsAny(() => _ = message.BodyObject); + Assert.Null(message._bodyObject); + message.Dispose(); + + var first = shared.GetReceiveMessageHandler(); + var second = shared.GetReceiveMessageHandler(); + Assert.Same(readRequest, first); + Assert.NotSame(first, second); + first.Reset(); + second.Reset(); + } + + [Fact] + public void Message_Dispose_ReleasesLazyBodyBuffer() + { + using var serviceProvider = CreateServiceProvider(); + var shared = CreateMessageHandlerShared(serviceProvider); + using var bodyWriter = new ArcBufferWriter(); + bodyWriter.Write([1, 2, 3]); + var readRequest = new MessageReadRequest(shared); + readRequest.Body = bodyWriter.ConsumeSlice(3); + typeof(MessageReadRequest).GetField("_bodyLength", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(readRequest, 3); + var message = new Message(); + message.SetMessageReadRequest(readRequest); + + message.Dispose(); + + Assert.Null(message._bodyObject); + Assert.Equal(0, readRequest.BodyLength); + Assert.Equal(0, readRequest.Body.Length); + } + + [Theory] + [InlineData((int)Message.Directions.Request, (int)Message.ResponseTypes.None)] + [InlineData((int)Message.Directions.Response, (int)Message.ResponseTypes.Success)] + public void Message_FormattingFailure_PreservesLazyBodyAndResponseType(int directionValue, int responseTypeValue) + { + var direction = (Message.Directions)directionValue; + var responseType = (Message.ResponseTypes)responseTypeValue; + using var serviceProvider = CreateServiceProvider(); + using var shared = CreateMessageHandlerShared(serviceProvider); + using var bodyWriter = new ArcBufferWriter(); + bodyWriter.Write([0xff]); + var readRequest = new MessageReadRequest(shared); + readRequest._originalResponseType = responseType; + readRequest.Body = bodyWriter.ConsumeSlice(1); + typeof(MessageReadRequest).GetField("_bodyLength", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(readRequest, 1); + using var message = new Message { Direction = direction, Result = responseType }; + message.SetMessageReadRequest(readRequest); + + Assert.Contains("Unable to deserialize message body:", message.ToString()); + Assert.Contains("Unable to deserialize message body:", message.ToString()); + Assert.Same(readRequest, message._bodyObject); + Assert.Equal(responseType, message.Result); + Assert.Equal([0xff], readRequest.Body.ToArray()); + + using var serializer = new MessageSerializer(serviceProvider.GetRequiredService(), new SiloMessagingOptions()); + using var output = new ArcBufferWriter(); + var lengths = serializer.Write(output, message); + Assert.Equal(1, lengths.BodyLength); + + if (direction == Message.Directions.Request) + { + Assert.ThrowsAny(() => _ = message.BodyObject); + } + } + + [Fact] + public void MessageHandlerShared_DoesNotReuseSerializersAcrossInstances() + { + using var firstServiceProvider = CreateServiceProvider(); + using var secondServiceProvider = CreateServiceProvider(); + var first = CreateMessageHandlerShared(firstServiceProvider); + var second = CreateMessageHandlerShared(secondServiceProvider); + + var serializer = first.GetMessageSerializer(); + first.Return(serializer); + var other = second.GetMessageSerializer(); + + Assert.NotSame(serializer, other); + second.Return(other); + } + + [Fact] + public async Task ConnectionCommon_ConcurrentHandlerAcquisitionResolvesOnce() + { + using var services = CreateServiceProvider(); + using var shared = CreateMessageHandlerShared(services); + var common = CreateConnectionCommon(services, shared); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var acquisitions = Enumerable.Range(0, 16).Select(_ => Task.Run(async () => + { + await start.Task; + return common.MessageHandlerShared; + }, TestContext.Current.CancellationToken)).ToArray(); + + start.SetResult(); + var results = await Task.WhenAll(acquisitions).WaitAsync(TestContext.Current.CancellationToken); + + Assert.All(results, result => Assert.Same(shared, result)); + Assert.Same(shared, common.MessageHandlerShared); + common.ServiceProvider.Received(1).GetService(typeof(MessageHandlerShared)); + } + + [Fact] + public void MessageHandlerShared_DoesNotReuseHandlersAcrossInstances() + { + using var firstServiceProvider = CreateServiceProvider(); + using var secondServiceProvider = CreateServiceProvider(); + var first = CreateMessageHandlerShared(firstServiceProvider); + var second = CreateMessageHandlerShared(secondServiceProvider); + + var readHandler = first.GetReceiveMessageHandler(); + first.Return(readHandler); + var otherReadHandler = second.GetReceiveMessageHandler(); + + Assert.NotSame(readHandler, otherReadHandler); + second.Return(otherReadHandler); + + var writeHandler = first.GetSendMessageHandler(); + first.Return(writeHandler); + var otherWriteHandler = second.GetSendMessageHandler(); + + Assert.NotSame(writeHandler, otherWriteHandler); + second.Return(otherWriteHandler); + } + + [Fact] + public void MessageHandlerShared_RejectsHandlerAcquisitionAfterDispose() + { + using var serviceProvider = CreateServiceProvider(); + var shared = CreateMessageHandlerShared(serviceProvider); + shared.Dispose(); + + Assert.Throws(() => shared.GetSendMessageHandler()); + Assert.Throws(() => shared.GetReceiveMessageHandler()); + } + + [Fact] + public async Task MessageHandlerShared_DisposeWaitsForBorrowedSerializer() + { + using var serviceProvider = CreateServiceProvider(); + var shared = CreateMessageHandlerShared(serviceProvider); + var serializer = shared.GetMessageSerializer(); + + var disposeTask = Task.Run(shared.Dispose, TestContext.Current.CancellationToken); + await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken); + Assert.False(disposeTask.IsCompleted); + + shared.Return(serializer); + + await disposeTask.WaitAsync(TestContext.Current.CancellationToken); + Assert.Throws(() => shared.GetMessageSerializer()); + } + + [Fact] + public async Task Connection_CloseAsyncWaitsForActiveSendWorker() + { + using var serviceProvider = CreateServiceProvider(); + var shared = CreateMessageHandlerShared(serviceProvider); + var connectionShared = CreateConnectionCommon(serviceProvider, shared); + await using var transport = new CapturingTransport(); + using var connection = new BlockingSendConnection( + transport, + connectionShared, + shared.MessageCenter, + TestContext.Current.CancellationToken); + connection.Send(new Message()); + await connection.PrepareEntered.WaitAsync(TestContext.Current.CancellationToken); + + var closeTask = connection.CloseAsync(null); + await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken); + Assert.False(closeTask.IsCompleted); + + connection.ReleaseSend(); + await closeTask.WaitAsync(TestContext.Current.CancellationToken); + + shared.Dispose(); + } + + [Fact] + public async Task MessageHandlerShared_DisposeWaitsForQueuedSendWorker() + { + using var serviceProvider = CreateServiceProvider(); + var shared = CreateMessageHandlerShared(serviceProvider); + var connectionShared = CreateConnectionCommon(serviceProvider, shared); + await using var transport = new CapturingTransport(); + using var connection = new BlockingSendConnection( + transport, + connectionShared, + shared.MessageCenter, + TestContext.Current.CancellationToken); + connection.Send(new Message()); + await connection.PrepareEntered.WaitAsync(TestContext.Current.CancellationToken); + + var disposeTask = Task.Run(shared.Dispose, TestContext.Current.CancellationToken); + await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken); + Assert.False(disposeTask.IsCompleted); + + connection.ReleaseSend(); + await disposeTask.WaitAsync(TestContext.Current.CancellationToken); + await connection.CloseAsync(null).WaitAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task MessageHandlerShared_DisposeAllowsLeasedSendWorkToFinish() + { + using var serviceProvider = CreateServiceProvider(); + var shared = CreateMessageHandlerShared(serviceProvider); + Assert.True(shared.TryAcquireSendWork()); + + var disposeTask = Task.Run(shared.Dispose, TestContext.Current.CancellationToken); + await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken); + Assert.False(disposeTask.IsCompleted); + + var request = shared.GetSendMessageHandler(); + request.Reset(); + shared.ReleaseSendWork(); + + await disposeTask.WaitAsync(TestContext.Current.CancellationToken); + Assert.Throws(() => shared.GetSendMessageHandler()); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(-1, 0)] + [InlineData(1, -1)] + public void MessageSerializer_ValidateFrameLengths_RejectsMalformedLengths(int headerLength, int bodyLength) + { + using var serviceProvider = CreateServiceProvider(); + var serializer = new MessageSerializer(serviceProvider.GetRequiredService(), new SiloMessagingOptions()); + + Assert.Throws(() => serializer.ValidateFrameLengths(headerLength, bodyLength)); + } + + [Fact] + public void MessageSerializer_ValidateFrameLengths_RejectsOverflow() + { + using var serviceProvider = CreateServiceProvider(); + var serializer = new MessageSerializer( + serviceProvider.GetRequiredService(), + new SiloMessagingOptions { MaxMessageHeaderSize = int.MaxValue, MaxMessageBodySize = int.MaxValue }); + + Assert.Throws(() => serializer.ValidateFrameLengths(int.MaxValue, int.MaxValue)); + } + + [Fact] + public void MessageWriteRequest_SerializationFailure_PreservesValidPrefix() + { + using var serviceProvider = CreateServiceProvider(new SiloMessagingOptions { MaxMessageBodySize = 1 }); + var shared = CreateMessageHandlerShared(serviceProvider); + var request = new MessageWriteRequest(shared); + var valid = new Message(); + var invalid = new Message { BodyObject = new byte[2] }; + + request.WriteMessage(valid); + var validLength = request.Length; + + Assert.Throws(() => request.WriteMessage(invalid)); + Assert.Equal(validLength, request.Length); + Assert.Equal(1, request.MessageCount); + Assert.Same(valid, request.GetMessage(0)); + request.Reset(); + } + + [Fact] + public void MessageWriteRequest_LargeMessageState_TracksFramesAndAdaptsPageSize() + { + using var serviceProvider = CreateServiceProvider(); + var shared = CreateMessageHandlerShared(serviceProvider); + var request = shared.GetSendMessageHandler(); + + request.WriteMessage(new Message()); + Assert.False(request.HasLargeMessages); + request.Reset(); + + request = shared.GetSendMessageHandler(); + request.WriteMessage(new Message { BodyObject = new byte[8 * 1024] }); + Assert.True(request.HasLargeMessages); + + request.Reset(); + var reused = shared.GetSendMessageHandler(); + Assert.Same(request, reused); + Assert.False(reused.HasLargeMessages); + + reused.WriteMessage(new Message { BodyObject = new byte[16 * 1024] }); + var segmentCount = 0; + using var slice = reused.Buffers.ConsumeSlice(reused.Buffers.Length); + var segments = slice.ArraySegments; + while (segments.MoveNext()) + { + segmentCount++; + } + + Assert.Equal(1, segmentCount); + reused.Reset(); + } + + [Fact] + public async Task MessageTransportStream_ReadCancellation_ClosesInnerTransport() + { + var transport = new CancelableTransport(); + await using var stream = new MessageTransportStream(transport, MemoryPool.Shared); + using var cancellation = new CancellationTokenSource(); + + var read = stream.ReadAsync(new byte[1], cancellation.Token).AsTask(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => read); + Assert.True(transport.CloseCalled); + } + + [Fact] + public async Task MessageTransportStream_WriteCancellation_ClosesInnerTransport() + { + var transport = new CancelableTransport(); + await using var stream = new MessageTransportStream(transport, MemoryPool.Shared); + using var cancellation = new CancellationTokenSource(); + + var write = stream.WriteAsync(new byte[1], cancellation.Token).AsTask(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => write); + Assert.True(transport.CloseCalled); + } + + [Fact] + public void MessageTransportStream_SynchronousWrite_UsesRequestedLength() + { + var transport = new CapturingTransport(); + using var stream = new MessageTransportStream(transport, MemoryPool.Shared); + byte[] bytes = [1, 2, 3]; + + stream.Write(bytes); + + Assert.Equal(bytes, transport.Written); + } + + [Fact] + public void MessageTransportStream_SynchronousRead_ReturnsTransportBytes() + { + byte[] bytes = [1, 2, 3]; + var transport = new ImmediateReadTransport(bytes); + using var stream = new MessageTransportStream(transport, MemoryPool.Shared); + Span destination = stackalloc byte[bytes.Length]; + + var bytesRead = stream.Read(destination); + + Assert.Equal(bytes.Length, bytesRead); + Assert.Equal(bytes, destination.ToArray()); + } + + [Fact] + public async Task SocketMessageTransport_ZeroLengthWrite_Completes() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + using var clientSocket = new Socket(SocketType.Stream, ProtocolType.Tcp); + var connect = clientSocket.ConnectAsync(listener.LocalEndpoint, TestContext.Current.CancellationToken); + using var serverSocket = await listener.AcceptSocketAsync(TestContext.Current.CancellationToken); + await connect; + await using var transport = new SocketMessageTransport(clientSocket, NullLogger.Instance); + transport.Start(); + using var request = new EmptyWriteRequest(); + + Assert.True(transport.EnqueueWrite(request)); + await request.Completion.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + await transport.CloseAsync(null, TestContext.Current.CancellationToken); + listener.Stop(); + } + + [Fact] + public async Task SocketMessageTransport_ReadFin_InterruptsBlockedWrite() + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cancellation.CancelAfter(TimeSpan.FromSeconds(10)); + using var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + listener.Listen(1); + var accept = listener.AcceptAsync(cancellation.Token).AsTask(); + using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) { SendBufferSize = 1024 }; + await client.ConnectAsync(listener.LocalEndPoint!, cancellation.Token); + using var peer = await accept; + peer.ReceiveBufferSize = 1024; + await using var transport = new SocketMessageTransport(client, NullLogger.Instance); + using var request = new BufferedWriteRequest(new byte[2 * 1024 * 1024]); + var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = transport.Closed.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), closed); + transport.Start(); + + Assert.True(transport.EnqueueWrite(request)); + Assert.Equal(1, await peer.ReceiveAsync(new byte[1], SocketFlags.None, cancellation.Token)); + Assert.False(request.Completion.IsCompleted); + Assert.True(transport.EnqueueRead(new PendingReadRequest())); + peer.Shutdown(SocketShutdown.Send); + + await closed.Task.WaitAsync(cancellation.Token); + Assert.NotNull(await Record.ExceptionAsync(() => request.Completion)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TcpMessageTransportConnector_AppliesDualModeToIpv6Socket(bool dualMode) + { + if (!Socket.OSSupportsIPv6) + { + throw Xunit.Sdk.SkipException.ForSkip("IPv6 is not supported."); + } + + var options = Substitute.For>(); + options.CurrentValue.Returns(new TcpMessageTransportOptions + { + DualMode = dualMode, + FastPath = false + }); + var connector = new TcpMessageTransportConnector(options, NullLoggerFactory.Instance); + var listener = new TcpListener(IPAddress.IPv6Loopback, 0); + listener.Server.DualMode = false; + listener.Start(); + try + { + var connectTask = connector.CreateAsync( + listener.LocalEndpoint, + TestContext.Current.CancellationToken).AsTask(); + using var acceptedSocket = await listener.AcceptSocketAsync(TestContext.Current.CancellationToken); + await using var transport = await connectTask; + var socket = (Socket)typeof(SocketMessageTransport) + .GetField("_socket", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(transport)!; + + Assert.Equal(dualMode, socket.DualMode); + + await transport.CloseAsync(null, TestContext.Current.CancellationToken); + } + finally + { + listener.Stop(); + } + } + + [Fact] + public async Task StreamMessageTransport_WriteFailure_WakesIdleReadLoop() + { + await using var transport = new TestStreamMessageTransport(new FailingWriteStream()); + transport.Start(); + using var request = new BufferedWriteRequest([1]); + var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = transport.Closed.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), closed); + + Assert.True(transport.EnqueueWrite(request)); + await Assert.ThrowsAsync(() => request.Completion); + await closed.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + } + + [Fact] + public async Task TlsConnector_ConstructionFailure_DisposesInnerTransport() + { + var inner = new TrackingTransport(); + var options = Substitute.For>(); + options.CurrentValue.Returns(new TlsOptions { ClientCertificateMode = RemoteCertificateMode.RequireCertificate }); + await using var connector = new TlsMessageTransportConnector(new TestConnector(inner), options, NullLoggerFactory.Instance); + + await Assert.ThrowsAsync( + () => connector.CreateAsync( + new IPEndPoint(IPAddress.Loopback, 1), + TestContext.Current.CancellationToken).AsTask()); + + Assert.True(inner.Disposed); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task TlsConnector_ClientAuthenticationCallbackSeesDefaultsAndCanOverrideOptions(bool checkRevocation, bool overrideRevocation) + { + var inner = new TrackingTransport(); + var callbackOptions = new TaskCompletionSource<(TlsClientAuthenticationOptions Options, X509RevocationMode DefaultMode)>(TaskCreationOptions.RunContinuationsAsynchronously); + SslStream? callbackStream = null; + var options = Substitute.For>(); + options.CurrentValue.Returns(new TlsOptions + { + ClientCertificateMode = RemoteCertificateMode.NoCertificate, + CheckCertificateRevocation = checkRevocation, + OnAuthenticateAsClient = (connection, sslOptions) => + { + callbackStream = connection.Features.Get(); + sslOptions.TargetHost = "localhost"; + var defaultMode = sslOptions.CertificateRevocationCheckMode; + if (overrideRevocation) + { + sslOptions.CertificateRevocationCheckMode = checkRevocation ? X509RevocationMode.NoCheck : X509RevocationMode.Online; + } + + callbackOptions.TrySetResult((sslOptions, defaultMode)); + } + }); + await using var connector = new TlsMessageTransportConnector(new TestConnector(inner), options, NullLoggerFactory.Instance); + await using var transport = await connector.CreateAsync( + new IPEndPoint(IPAddress.Loopback, 1), + TestContext.Current.CancellationToken); + + var (configuredOptions, defaultMode) = await callbackOptions.Task.WaitAsync(TestContext.Current.CancellationToken); + var sslOptions = Assert.IsType(configuredOptions.SslClientAuthenticationOptions); + + Assert.NotNull(callbackStream); + Assert.Same(transport.Features.Get(), callbackStream); + Assert.Equal("localhost", sslOptions.TargetHost); + Assert.Equal([new SslApplicationProtocol("Orleans1")], sslOptions.ApplicationProtocols); + Assert.Equal(checkRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck, defaultMode); + var expectedMode = checkRevocation != overrideRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck; + Assert.Equal(expectedMode, sslOptions.CertificateRevocationCheckMode); + } + + [Theory] + [InlineData(RemoteCertificateMode.NoCertificate)] + [InlineData(RemoteCertificateMode.AllowCertificate)] + [InlineData(RemoteCertificateMode.RequireCertificate)] + public async Task TlsConnector_ClientCertificateSelectorHonorsCertificateMode(RemoteCertificateMode mode) + { + var callbackOptions = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var options = Substitute.For>(); + options.CurrentValue.Returns(new TlsOptions + { + ClientCertificateMode = mode, + LocalClientCertificateSelector = (_, _, _, _, _) => null, + OnAuthenticateAsClient = (_, sslOptions) => + { + sslOptions.TargetHost = "localhost"; + callbackOptions.TrySetResult(sslOptions); + } + }); + await using var connector = new TlsMessageTransportConnector(new TestConnector(new TrackingTransport()), options, NullLoggerFactory.Instance); + await using var transport = await connector.CreateAsync( + new IPEndPoint(IPAddress.Loopback, 1), + TestContext.Current.CancellationToken); + var configuredOptions = await callbackOptions.Task.WaitAsync(TestContext.Current.CancellationToken); + var selector = configuredOptions.LocalCertificateSelectionCallback; + Assert.NotNull(selector); + + if (mode is RemoteCertificateMode.RequireCertificate) + { + var error = Assert.Throws(() => + selector(new object(), "localhost", new X509CertificateCollection(), null, [])); + Assert.Equal("No certificate provided for client authentication.", error.Message); + } + else + { + Assert.Null(selector(new object(), "localhost", new X509CertificateCollection(), null, [])); + } + } + + [Fact] + public async Task TlsListener_ConstructionFailure_DisposesConnectionAndContinuesAccepting() + { + var inner = new TrackingTransport(); + var options = Substitute.For>(); + options.Get(Arg.Any()).Returns(new TlsOptions()); + await using var listener = new TlsMessageTransportListener(new TestListener(inner), options, NullLoggerFactory.Instance); + + Assert.Null(await listener.AcceptAsync(TestContext.Current.CancellationToken)); + Assert.True(inner.Disposed); + } + + private static ServiceProvider CreateServiceProvider(SiloMessagingOptions? options = null) => new ServiceCollection() + .AddMetrics() + .AddSerializer() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddTransient(sp => new MessageSerializer(sp.GetRequiredService(), options ?? new SiloMessagingOptions())) + .BuildServiceProvider(); + + private static MessageHandlerShared CreateMessageHandlerShared(IServiceProvider serviceProvider) + { + var messagingInstruments = serviceProvider.GetRequiredService(); + var messagingTrace = new MessagingTrace( + NullLoggerFactory.Instance, + messagingInstruments, + serviceProvider.GetRequiredService()); + return new( + messagingTrace, + new ConnectionTrace(NullLoggerFactory.Instance), + () => serviceProvider.GetRequiredService(), + new MessageFactory(serviceProvider.GetRequiredService(), NullLogger.Instance, messagingTrace), + Substitute.For(), + messagingInstruments); + } + + private static ConnectionCommon CreateConnectionCommon(IServiceProvider serviceProvider, MessageHandlerShared shared) + { + var connectionServices = Substitute.For(); + connectionServices.GetService(typeof(MessageHandlerShared)).Returns(shared); + return new( + connectionServices, + shared.MessageFactory, + shared.MessagingTrace, + shared.ConnectionTrace, + shared.MessagingInstruments, + serviceProvider.GetRequiredService(), + new NoOpMessageStatisticsSink()); + } + + private sealed class BlockingSendConnection( + MessageTransport transport, + ConnectionCommon shared, + IMessageCenter messageCenter, + CancellationToken cancellationToken) : Connection(transport, shared), IDisposable + { + private readonly TaskCompletionSource _prepareEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly ManualResetEventSlim _releaseSend = new(); + + public Task PrepareEntered => _prepareEntered.Task; + protected override ConnectionDirection ConnectionDirection => ConnectionDirection.SiloToSilo; + protected override TimeSpan CloseConnectionTimeout => TimeSpan.FromSeconds(1); + protected override IMessageCenter MessageCenter => messageCenter; + + public void ReleaseSend() => _releaseSend.Set(); + + protected override bool PrepareMessageForSend(Message msg) + { + _prepareEntered.TrySetResult(); + _releaseSend.Wait(cancellationToken); + return true; + } + + protected override void RetryMessage(Message msg, Exception? ex = null) => msg.Dispose(); + protected internal override void OnReceivedMessage(Message message) { } + protected internal override void RecordMessageReceive(Message message, int totalBytes, int headerBytes) { } + protected internal override void RecordMessageSend(Message message, int totalBytes, int headerBytes) { } + public void Dispose() => _releaseSend.Dispose(); + } + + private sealed class CancelableTransport : MessageTransport + { + private ReadRequest? _read; + private WriteRequest? _write; + + public bool CloseCalled { get; private set; } + public override CancellationToken Closed => default; + public override IFeatureCollection Features { get; } = new FeatureCollection(); + + public override bool EnqueueRead(ReadRequest request) + { + _read = request; + return true; + } + + public override bool EnqueueWrite(WriteRequest request) + { + _write = request; + return true; + } + + public override ValueTask CloseAsync(Exception? closeException, CancellationToken cancellationToken = default) + { + CloseCalled = true; + var error = closeException ?? new ConnectionClosedException(); + _read?.OnCanceled(); + _write?.SetException(error); + return default; + } + } + + private sealed class CapturingTransport : MessageTransport + { + public byte[]? Written { get; private set; } + public override CancellationToken Closed => default; + public override IFeatureCollection Features { get; } = new FeatureCollection(); + public override bool EnqueueRead(ReadRequest request) => false; + + public override bool EnqueueWrite(WriteRequest request) + { + Written = new byte[request.Buffers.Length]; + request.Buffers.Consume(Written); + request.SetResult(); + return true; + } + + public override ValueTask CloseAsync(Exception? closeException, CancellationToken cancellationToken = default) => default; + } + + private sealed class ImmediateReadTransport : MessageTransport + { + private readonly ArcBufferWriter _buffer = new(); + + public ImmediateReadTransport(ReadOnlySpan bytes) => _buffer.Write(bytes); + + public override CancellationToken Closed => default; + public override IFeatureCollection Features { get; } = new FeatureCollection(); + + public override bool EnqueueRead(ReadRequest request) + { + request.OnRead(new ArcBufferReader(_buffer)); + return true; + } + + public override bool EnqueueWrite(WriteRequest request) => false; + + public override ValueTask CloseAsync(Exception? closeException, CancellationToken cancellationToken = default) => default; + + public override ValueTask DisposeAsync() + { + _buffer.Dispose(); + return default; + } + } + + private sealed class EmptyWriteRequest : WriteRequest, IDisposable + { + private readonly ArcBufferWriter _buffer = new(); + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public EmptyWriteRequest() => Buffers = new(_buffer); + public Task Completion => _completion.Task; + public override void SetResult() => _completion.TrySetResult(); + public override void SetException(Exception error) => _completion.TrySetException(error); + public void Dispose() => _buffer.Dispose(); + } + + private sealed class PendingReadRequest : ReadRequest + { + public override bool OnRead(ArcBufferReader buffer) => false; + public override void OnError(Exception error) { } + public override void OnCanceled() { } + } + + private sealed class BufferedWriteRequest : WriteRequest, IDisposable + { + private readonly ArcBufferWriter _buffer = new(); + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public BufferedWriteRequest(ReadOnlySpan bytes) + { + _buffer.Write(bytes); + Buffers = new(_buffer); + } + + public Task Completion => _completion.Task; + public override void SetResult() => _completion.TrySetResult(); + public override void SetException(Exception error) => _completion.TrySetException(error); + public void Dispose() => _buffer.Dispose(); + } + + private sealed class TestStreamMessageTransport(Stream stream) : StreamMessageTransport(NullLogger.Instance) + { + protected override Stream Stream { get; } = stream; + } + + private sealed class FailingWriteStream : MemoryStream + { + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => + ValueTask.FromException(new IOException("Write failed")); + } + + private sealed class TrackingTransport : MessageTransport + { + public bool Disposed { get; private set; } + public override CancellationToken Closed => default; + public override IFeatureCollection Features { get; } = new FeatureCollection(); + public override bool EnqueueRead(ReadRequest request) => false; + public override bool EnqueueWrite(WriteRequest request) => false; + public override ValueTask CloseAsync(Exception? closeException, CancellationToken cancellationToken = default) => default; + + public override ValueTask DisposeAsync() + { + Disposed = true; + return default; + } + } + + private sealed class TestConnector(MessageTransport transport) : MessageTransportConnector + { + public override IFeatureCollection Features { get; } = new FeatureCollection(); + public override bool IsValid => true; + public override ValueTask CreateAsync(EndPoint endPoint, CancellationToken cancellationToken = default) => new(transport); + } + + private sealed class TestListener(MessageTransport transport) : MessageTransportListener + { + private MessageTransport? _transport = transport; + public override IFeatureCollection Features { get; } = new FeatureCollection(); + public override bool IsValid => true; + public override string ListenerName => "test"; + public override ValueTask BindAsync(CancellationToken cancellationToken = default) => default; + public override ValueTask UnbindAsync(CancellationToken cancellationToken = default) => default; + + public override ValueTask AcceptAsync(CancellationToken cancellationToken = default) + { + var result = _transport; + _transport = null; + return new(result); + } + } +} diff --git a/test/Orleans.Core.Tests/Networking/SocketMessageTransportWriteTests.cs b/test/Orleans.Core.Tests/Networking/SocketMessageTransportWriteTests.cs new file mode 100644 index 00000000000..13c6f5b0daf --- /dev/null +++ b/test/Orleans.Core.Tests/Networking/SocketMessageTransportWriteTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Orleans.Connections.Transport; +using Orleans.Connections.Transport.Sockets; +using Orleans.Serialization.Buffers; +using TestExtensions; +using Xunit; + +namespace Orleans.Core.Tests.Networking; + +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Runtime")] +public class SocketMessageTransportWriteTests +{ + [Theory] + [InlineData(31, false)] + [InlineData(32, false)] + [InlineData(33, false)] + [InlineData(63, true)] + [InlineData(64, true)] + [InlineData(65, true)] + public async Task SingleWrite_CompletesAtBufferLimitWithoutAnotherRequest(int segments, bool largeMessages) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cancellation.CancelAfter(TimeSpan.FromSeconds(10)); + using var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + listener.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + listener.Listen(1); + var accept = listener.AcceptAsync(cancellation.Token).AsTask(); + using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await client.ConnectAsync(listener.LocalEndPoint!, cancellation.Token); + using var peer = await accept; + await using var transport = new SocketMessageTransport(client, NullLogger.Instance); + var expected = Enumerable.Range(0, segments * ArcBufferWriter.MinimumPageSize) + .Select(static index => (byte)(index % 251)).ToArray(); + using var request = new SegmentedWriteRequest(expected, largeMessages); + using (var slice = request.Buffers.PeekSlice(expected.Length)) + { + var enumerator = slice.ArraySegments; + for (var i = 0; i < segments; i++) + { + Assert.True(enumerator.MoveNext()); + Assert.Equal(ArcBufferWriter.MinimumPageSize, enumerator.Current.Count); + Assert.Equal(i == segments - 1, enumerator.IsCompleted); + } + } + + transport.Start(); + Assert.True(transport.EnqueueWrite(request)); + var actual = new byte[expected.Length]; + var received = 0; + while (received < actual.Length) + { + var count = await peer.ReceiveAsync(actual.AsMemory(received), SocketFlags.None, cancellation.Token); + Assert.NotEqual(0, count); + received += count; + } + + await request.Completion.WaitAsync(cancellation.Token); + + Assert.Equal(expected, actual); + Assert.Equal(0, request.Buffers.Length); + Assert.False(transport.Closed.IsCancellationRequested); + } + + private sealed class SegmentedWriteRequest : WriteRequest, IDisposable + { + private readonly ArcBufferWriter _buffer = new(); + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public SegmentedWriteRequest(byte[] bytes, bool largeMessages) + { + _buffer.Write(bytes); + Buffers = new(_buffer); + HasLargeMessages = largeMessages; + } + + public Task Completion => _completion.Task; + internal override bool HasLargeMessages { get; } + + public override void SetResult() => _completion.SetResult(); + public override void SetException(Exception error) => _completion.SetException(error); + public void Dispose() => _buffer.Dispose(); + } +} diff --git a/test/Orleans.Core.Tests/Networking/TcpMessageTransportKeepAliveTests.cs b/test/Orleans.Core.Tests/Networking/TcpMessageTransportKeepAliveTests.cs new file mode 100644 index 00000000000..92f10e804c5 --- /dev/null +++ b/test/Orleans.Core.Tests/Networking/TcpMessageTransportKeepAliveTests.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using Orleans.Connections.Transport.Sockets; +using TestExtensions; +using Xunit; + +namespace Orleans.Core.Tests.Networking; + +[TestCategory("BVT")] +[TestSuite("BVT")] +[TestProvider("None")] +[TestArea("Runtime")] +public class TcpMessageTransportKeepAliveTests +{ + private const string ListenerName = "silo"; + + [Theory] + [InlineData(SocketOptionName.KeepAlive, SocketError.ProtocolOption, false)] + [InlineData(SocketOptionName.TcpKeepAliveTime, SocketError.ProtocolOption, false)] + [InlineData(SocketOptionName.TcpKeepAliveInterval, SocketError.OperationNotSupported, false)] + [InlineData(SocketOptionName.TcpKeepAliveRetryCount, SocketError.ProtocolNotSupported, false)] + [InlineData(SocketOptionName.TcpKeepAliveRetryCount, SocketError.SocketNotSupported, false)] + [InlineData(SocketOptionName.TcpKeepAliveTime, SocketError.Success, true)] + public void ConfigureKeepAlive_UnsupportedOption_LogsAndRetainsSupportedSettings( + SocketOptionName unsupportedOption, SocketError socketError, bool platformException) + { + Exception failure = platformException + ? new PlatformNotSupportedException() + : new SocketException((int)socketError); + var logger = Substitute.For(); + logger.IsEnabled(LogLevel.Warning).Returns(true); + var attempted = new List<(SocketOptionLevel, SocketOptionName, int)>(); + var applied = new List(); + + SocketExtensions.ConfigureKeepAlive(new TcpMessageTransportOptions(), logger, (level, option, value) => + { + attempted.Add((level, option, value)); + if (option == unsupportedOption) + { + throw failure; + } + + applied.Add(option); + }); + + var expected = unsupportedOption == SocketOptionName.KeepAlive + ? new[] { (SocketOptionLevel.Socket, SocketOptionName.KeepAlive, 1) } + : new[] + { + (SocketOptionLevel.Socket, SocketOptionName.KeepAlive, 1), + (SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 90), + (SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 30), + (SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 10) + }; + Assert.Equal(expected, attempted); + Assert.Equal(expected.Select(entry => entry.Item2).Where(option => option != unsupportedOption), applied); + var log = Assert.Single(logger.ReceivedCalls(), call => call.GetMethodInfo().Name == nameof(ILogger.Log)); + Assert.Equal(LogLevel.Warning, log.GetArguments()[0]); + Assert.Equal("UnsupportedKeepAliveOption", Assert.IsType(log.GetArguments()[1]).Name); + Assert.Contains(unsupportedOption.ToString(), log.GetArguments()[2]!.ToString()); + Assert.Same(failure, log.GetArguments()[3]); + } + + [Theory] + [InlineData(SocketError.InvalidArgument)] + [InlineData(SocketError.AccessDenied)] + [InlineData(SocketError.NotSocket)] + public void ConfigureKeepAlive_OtherSocketErrors_Propagate(SocketError socketError) + { + var failure = new SocketException((int)socketError); + var logger = Substitute.For(); + var attempted = new List(); + + var thrown = Assert.Throws(() => + SocketExtensions.ConfigureKeepAlive(new TcpMessageTransportOptions(), logger, (_, option, _) => + { + attempted.Add(option); + if (option == SocketOptionName.TcpKeepAliveTime) + { + throw failure; + } + })); + + Assert.Same(failure, thrown); + Assert.Equal([SocketOptionName.KeepAlive, SocketOptionName.TcpKeepAliveTime], attempted); + Assert.DoesNotContain(logger.ReceivedCalls(), call => call.GetMethodInfo().Name == nameof(ILogger.Log)); + } + + [Theory] + [InlineData(false, true, 90, 30, 10)] + [InlineData(true, true, 17, 7, 4)] + [InlineData(true, false, 17, 7, 4)] + public async Task Transports_ApplyKeepAliveSettingsToOutboundListeningAndAcceptedSockets( + bool customizeOptions, + bool enabled, + int timeSeconds, + int intervalSeconds, + int retryCount) + { + var options = new TcpMessageTransportOptions { FastPath = false }; + if (customizeOptions) + { + options.KeepAlive = enabled; + options.KeepAliveTimeSeconds = timeSeconds; + options.KeepAliveIntervalSeconds = intervalSeconds; + options.KeepAliveRetryCount = retryCount; + } + + var tcpOptions = Substitute.For>(); + tcpOptions.CurrentValue.Returns(options); + tcpOptions.Get(ListenerName).Returns(options); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cancellation.CancelAfter(TimeSpan.FromSeconds(10)); + await using var listener = CreateListener(tcpOptions); + await listener.BindAsync(cancellation.Token); + var listenSocket = GetSocket(listener, "_listenSocket"); + AssertKeepAlive(listenSocket, enabled, timeSeconds, intervalSeconds, retryCount); + + var accept = listener.AcceptAsync(cancellation.Token).AsTask(); + await using var connector = new TcpMessageTransportConnector(tcpOptions, NullLoggerFactory.Instance); + await using var outbound = await connector.CreateAsync(listenSocket.LocalEndPoint!, cancellation.Token); + await using var inbound = Assert.IsType(await accept); + + AssertKeepAlive(GetSocket(outbound, "_socket"), enabled, timeSeconds, intervalSeconds, retryCount); + AssertKeepAlive(GetSocket(inbound, "_socket"), enabled, timeSeconds, intervalSeconds, retryCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Listener_AppliesCurrentNamedKeepAliveSettingsToAcceptedSockets(bool enabled) + { + var tcpOptions = Substitute.For>(); + var originalOptions = new TcpMessageTransportOptions { FastPath = false }; + tcpOptions.CurrentValue.Returns(originalOptions); + tcpOptions.Get(ListenerName).Returns(originalOptions); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cancellation.CancelAfter(TimeSpan.FromSeconds(10)); + await using var listener = CreateListener(tcpOptions); + await listener.BindAsync(cancellation.Token); + var listenSocket = GetSocket(listener, "_listenSocket"); + AssertKeepAlive(listenSocket, true, 90, 30, 10); + + tcpOptions.Get(ListenerName).Returns(new TcpMessageTransportOptions + { + KeepAlive = enabled, + KeepAliveTimeSeconds = 19, + KeepAliveIntervalSeconds = 6, + KeepAliveRetryCount = 3 + }); + + var accept = listener.AcceptAsync(cancellation.Token).AsTask(); + using var peer = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await peer.ConnectAsync(listenSocket.LocalEndPoint!, cancellation.Token); + await using var inbound = Assert.IsType(await accept); + + AssertKeepAlive(GetSocket(inbound, "_socket"), enabled, 19, 6, 3); + AssertKeepAlive(listenSocket, true, 90, 30, 10); + } + + private static TcpMessageTransportListener CreateListener(IOptionsMonitor tcpOptions) + { + var listenerOptions = Substitute.For>(); + listenerOptions.Get(ListenerName).Returns(new TcpMessageTransportListenerOptions + { + Endpoint = new IPEndPoint(IPAddress.Loopback, 0) + }); + + return new TcpMessageTransportListener(ListenerName, tcpOptions, listenerOptions, NullLoggerFactory.Instance); + } + + private static Socket GetSocket(object owner, string fieldName) => + Assert.IsType(owner.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)); + + private static void AssertKeepAlive(Socket socket, bool enabled, int timeSeconds, int intervalSeconds, int retryCount) + { + Assert.Equal(enabled ? 1 : 0, (int)socket.GetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive)!); + if (enabled) + { + Assert.Equal(timeSeconds, (int)socket.GetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime)!); + Assert.Equal(intervalSeconds, (int)socket.GetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval)!); + Assert.Equal(retryCount, (int)socket.GetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount)!); + } + } +} diff --git a/test/Orleans.Core.Tests/Runtime/BufferedResponse.cs b/test/Orleans.Core.Tests/Runtime/BufferedResponse.cs new file mode 100644 index 00000000000..561197fc3fb --- /dev/null +++ b/test/Orleans.Core.Tests/Runtime/BufferedResponse.cs @@ -0,0 +1,79 @@ +using System; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Orleans; +using Orleans.Configuration; +using Orleans.Connections; +using Orleans.Messaging; +using Orleans.Runtime; +using Orleans.Runtime.Messaging; +using Orleans.Serialization; +using Orleans.Serialization.Buffers; +using Orleans.Serialization.Session; + +namespace UnitTests.Runtime; + +internal sealed class BufferedResponse : IDisposable +{ + private readonly MessageHandlerShared _shared; + + public BufferedResponse(IServiceProvider services, CorrelationId id, GrainId targetGrain, bool malformed) + : this(services, new Message + { + Direction = Message.Directions.Response, + Result = Message.ResponseTypes.Status, + Id = id, + TargetGrain = targetGrain, + BodyObject = new StatusResponse(isExecuting: true, isWaiting: false, ["processing"]) + }, malformed) + { + } + + public BufferedResponse(IServiceProvider services, Message message, bool malformed = false) + { + var instruments = new OrleansInstruments(services.GetRequiredService()); + var messaging = new MessagingInstruments(instruments); + var trace = new MessagingTrace(NullLoggerFactory.Instance, messaging, new MessagingProcessingInstruments(instruments)); + var factory = new MessageFactory(services.GetRequiredService(), NullLogger.Instance, trace); + var sessions = services.GetRequiredService(); + _shared = new MessageHandlerShared( + trace, + new ConnectionTrace(NullLoggerFactory.Instance), + () => new MessageSerializer(sessions, new SiloMessagingOptions()), + factory, + Substitute.For(), + messaging); + Message = message; + using var buffer = new ArcBufferWriter(); + int bodyLength; + if (malformed) + { + buffer.Write([0xff]); + bodyLength = 1; + } + else + { + using var serializer = new MessageSerializer(sessions, new SiloMessagingOptions()); + var lengths = serializer.Write(buffer, Message); + buffer.AdvanceReader(lengths.HeaderLength); + bodyLength = lengths.BodyLength; + } + + Request = _shared.GetReceiveMessageHandler(); + Request._originalResponseType = Message.Result; + Request.Body = buffer.ConsumeSlice(bodyLength); + typeof(MessageReadRequest).GetField("_bodyLength", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(Request, bodyLength); + Message.SetMessageReadRequest(Request); + } + + public Message Message { get; } + public MessageReadRequest Request { get; } + + public void Dispose() + { + Message.Dispose(); + _shared.Dispose(); + } +} diff --git a/test/Orleans.Core.Tests/Runtime/ClientObserverDrainLifecycleTests.cs b/test/Orleans.Core.Tests/Runtime/ClientObserverDrainLifecycleTests.cs index 6e2436ea1a1..e313ece85cc 100644 --- a/test/Orleans.Core.Tests/Runtime/ClientObserverDrainLifecycleTests.cs +++ b/test/Orleans.Core.Tests/Runtime/ClientObserverDrainLifecycleTests.cs @@ -29,6 +29,69 @@ namespace UnitTests.Runtime; [TestCategory("BVT")] public class ClientObserverDrainLifecycleTests { + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task ReceiveStatusResponse_DisposesMessageAndPreservesPendingCallback(bool knownRequest, bool malformed) + { + var fixture = new ExternalClientFixture(); + var callbacks = ReadCallbacks(fixture.Runtime); + var completion = new CallbackCompletionSource(); + using var request = new Message + { + Direction = Message.Directions.Request, + Id = new CorrelationId(2501), + SendingGrain = fixture.ObserverId.GrainId + }; + var callback = new CallbackData( + new SharedCallbackData( + message => callbacks.TryRemove(message.Id, out _), + NullLogger.Instance, + fixture.TimeProvider, + TimeSpan.FromMinutes(1), + cancelOnTimeout: false, + waitForCancellationAcknowledgement: false, + cancellationManager: null), + completion, + request, + new ApplicationRequestInstruments(fixture.Instruments)); + try + { + using var response = new BufferedResponse(fixture.Services, request.Id, request.SendingGrain, malformed); + if (knownRequest) + { + Assert.True(callbacks.TryAdd(request.Id, callback)); + } + + var error = Record.Exception(() => fixture.Runtime.ReceiveResponse(response.Message)); + Assert.Equal(malformed, error is InvalidCastException); + if (!malformed) + { + Assert.Null(error); + } + + Assert.Null(response.Message._bodyObject); + Assert.Equal(0, response.Request.Body.Length); + Assert.False(callback.IsCompleted); + Assert.Equal(0, completion.CompletionCount); + Assert.Equal(knownRequest, callbacks.ContainsKey(request.Id)); + if (knownRequest) + { + callback.OnTimeout(); + var timeout = Assert.IsType(completion.Exception); + Assert.Equal(!malformed, timeout.Message.Contains("processing", StringComparison.Ordinal)); + Assert.Equal(1, completion.CompletionCount); + Assert.Empty(callbacks); + } + } + finally + { + await fixture.CleanupAsync(); + } + } + [Fact] public async Task ClusterClientStop_AllowsUpperStageCleanupThenDrainsBeforeConnectionClose() { diff --git a/test/Orleans.Core.Tests/Runtime/HostedClientDrainTests.cs b/test/Orleans.Core.Tests/Runtime/HostedClientDrainTests.cs index 9701d2bec92..e25e0e47b99 100644 --- a/test/Orleans.Core.Tests/Runtime/HostedClientDrainTests.cs +++ b/test/Orleans.Core.Tests/Runtime/HostedClientDrainTests.cs @@ -36,6 +36,135 @@ namespace UnitTests.Runtime; [TestCategory("BVT")] public class HostedClientDrainTests { + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task ReceiveStatusResponse_DisposesMessageAndPreservesPendingCallback(bool knownRequest, bool malformed) + { + var fixture = new HostedFixture(nameof(ReceiveStatusResponse_DisposesMessageAndPreservesPendingCallback)); + var callbacks = ReadCallbacks(fixture.Runtime); + var completion = new CallbackCompletionSource(); + using var request = new Message + { + Direction = Message.Directions.Request, + Id = new CorrelationId(2051), + SendingGrain = fixture.Hosted.GrainId + }; + var callback = new CallbackData( + new SharedCallbackData( + message => callbacks.TryRemove((message.SendingGrain, message.Id), out _), + NullLogger.Instance, + fixture.Clock, + TimeSpan.FromMinutes(1), + cancelOnTimeout: false, + waitForCancellationAcknowledgement: false, + cancellationManager: null), + completion, + request, + fixture.ApplicationRequests); + try + { + using var response = new BufferedResponse(fixture.Runtime.ServiceProvider, request.Id, request.SendingGrain, malformed); + if (knownRequest) + { + Assert.True(callbacks.TryAdd((request.SendingGrain, request.Id), callback)); + } + + var error = Record.Exception(() => fixture.Runtime.ReceiveResponse(response.Message)); + Assert.Equal(malformed, error is InvalidCastException); + if (!malformed) + { + Assert.Null(error); + } + + Assert.Null(response.Message._bodyObject); + Assert.Equal(0, response.Request.Body.Length); + Assert.False(callback.IsCompleted); + Assert.Equal(0, completion.CompletionCount); + Assert.Equal(knownRequest, callbacks.ContainsKey((request.SendingGrain, request.Id))); + if (knownRequest) + { + callback.OnTimeout(); + var timeout = Assert.IsType(completion.Exception); + Assert.Equal(!malformed, timeout.Message.Contains("processing", StringComparison.Ordinal)); + Assert.Equal(1, completion.CompletionCount); + Assert.Empty(callbacks); + } + } + finally + { + await fixture.CleanupAsync(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReceiveCacheInvalidationRejection_DisposesMessageAndPreservesPendingCallback(bool knownRequest) + { + var fixture = new HostedFixture(nameof(ReceiveCacheInvalidationRejection_DisposesMessageAndPreservesPendingCallback)); + var callbacks = ReadCallbacks(fixture.Runtime); + var completion = new CallbackCompletionSource(); + using var request = new Message + { + Direction = Message.Directions.Request, + Id = new CorrelationId(2052), + SendingGrain = fixture.Hosted.GrainId + }; + var callback = new CallbackData( + new SharedCallbackData( + message => callbacks.TryRemove((message.SendingGrain, message.Id), out _), + NullLogger.Instance, + fixture.Clock, + TimeSpan.FromMinutes(1), + cancelOnTimeout: false, + waitForCancellationAcknowledgement: false, + cancellationManager: null), + completion, + request, + fixture.ApplicationRequests); + try + { + using var response = new BufferedResponse(fixture.Runtime.ServiceProvider, new Message + { + Direction = Message.Directions.Response, + Result = Message.ResponseTypes.Rejection, + Id = request.Id, + TargetGrain = request.SendingGrain, + TargetSilo = fixture.Address, + BodyObject = new RejectionResponse { RejectionType = Message.RejectionTypes.CacheInvalidation }, + CacheInvalidationHeader = [new GrainAddressCacheUpdate( + new GrainAddress { GrainId = GrainId.Create("test", "invalid"), SiloAddress = fixture.Address }, + validAddress: null)] + }); + if (knownRequest) + { + Assert.True(callbacks.TryAdd((request.SendingGrain, request.Id), callback)); + } + + fixture.Runtime.ReceiveResponse(response.Message); + + Assert.Null(response.Message._bodyObject); + Assert.Equal(0, response.Request.Body.Length); + Assert.False(callback.IsCompleted); + Assert.Equal(0, completion.CompletionCount); + Assert.Equal(knownRequest, callbacks.ContainsKey((request.SendingGrain, request.Id))); + if (knownRequest) + { + Assert.Same(callback, callbacks[(request.SendingGrain, request.Id)]); + callback.OnTimeout(); + Assert.Equal(1, completion.CompletionCount); + Assert.Empty(callbacks); + } + } + finally + { + await fixture.CleanupAsync(); + } + } + [Fact] public async Task OnStop_DrainsChannelAdmittedUndispatchedMessages() { @@ -392,7 +521,7 @@ internal HostedFixture(string test) Scopes, [new UntypedReferenceProvider(_root, referenceRuntime)]); var silo = Substitute.For(); // Address data only: no socket, port allocation, DNS, or silo startup. - silo.SiloAddress.Returns(SiloAddress.New(IPAddress.Loopback, 0, 1)); + silo.SiloAddress.Returns(Address); silo.GatewayAddress.Returns((SiloAddress)null!); Runtime = new InsideRuntimeClient( silo, Scopes, messageFactory, loggerFactory, options, trace, referenceActivator, @@ -402,9 +531,8 @@ internal HostedFixture(string test) deepCopier, Clock, mapping, instruments); // Constructor-only transport graph. GatewayAddress == null guards the throwing factory. - // Catalog/connections/placement/locator are only reached by transport operations: - // we never start/stop MessageCenter, send outbound calls, or deliver rejection/status - // responses. All observer bodies are valid OneWay invokables, including on failure. + // Local callback, status, and cache-invalidation paths use the configured runtime. + // Observer bodies use valid OneWay invokables, including on failure. var messageCenter = new MessageCenter( siloDetails: silo, messageFactory: messageFactory, @@ -450,6 +578,7 @@ internal HostedFixture(string test) internal HostedClient Hosted { get; } internal InsideRuntimeClient Runtime { get; } + internal SiloAddress Address { get; } = SiloAddress.New(IPAddress.Loopback, 0, 1); internal ScopeTrackingProvider Scopes { get; } internal ScopeMarker Marker { get; } internal FakeTimeProvider Clock { get; } = new(); diff --git a/test/Orleans.Core.Tests/Serialization/MessageSerializerTests.cs b/test/Orleans.Core.Tests/Serialization/MessageSerializerTests.cs index 9a1ec12146a..24f36ce4387 100644 --- a/test/Orleans.Core.Tests/Serialization/MessageSerializerTests.cs +++ b/test/Orleans.Core.Tests/Serialization/MessageSerializerTests.cs @@ -1,3 +1,4 @@ +/* using System.Buffers; using System.Buffers.Binary; using System.Collections.Generic; @@ -21,7 +22,6 @@ namespace UnitTests.Serialization /// Tests for Orleans message serialization functionality. /// [Collection(TestEnvironmentFixture.DefaultCollection)] - [TestArea("Serialization")] public class MessageSerializerTests { private readonly ITestOutputHelper output; @@ -93,94 +93,207 @@ private static void WaitForTimestamp(long timestamp) [TestSuite("Functional")] [TestProvider("None")] + [Fact, TestCategory("Functional"), TestCategory("Serialization")] + public void Message_Serialize_RoundTrip_Buffer() + { + for (var i = 0; i < 10; i++) + { + var writeBuffer = new PooledBuffer(); + var readBuffer = new PooledBuffer(); + try + { + var message = CreateTestMessage(); + var (headerLength, bodyLength) = this.messageSerializer.Write(ref writeBuffer, message); + + writeBuffer.CopyTo(ref readBuffer); + var bufferSlice = readBuffer.Slice(); + this.messageSerializer.Read(in bufferSlice, headerLength, bodyLength, out var deserializedMessage); + + CheckMessage(message, deserializedMessage); + } + finally + { + writeBuffer.Dispose(); + readBuffer.Dispose(); + RequestContext.Clear(); + } + } + } + + [Fact, TestCategory("Functional"), TestCategory("Serialization")] + public void Message_Serialize_RoundTrip_Request() + { + for (var i = 0; i < 10; i++) + { + var writeRequest = this.messageHandlerShared.GetSendMessageHandler(); + var message = CreateTestMessage(); + writeRequest.Initialize(message); + + var readRequest = messageHandlerShared.GetReceiveMessageHandler(); + + var writeBuffers = writeRequest.Buffers; + int writeLength; + do + { + writeLength = (int)Math.Min(writeBuffers.Length, readRequest.Buffer.Length); + writeBuffers.Slice(0, writeLength).CopyTo(readRequest.Buffer.Span); + writeBuffers = writeBuffers.Slice(writeLength); + } while (!readRequest.OnProgress(writeLength)); + + //var deserializedMessage = readRequest.TestReadMessage(); + CheckMessage(message, deserializedMessage); + } + } + + private static void CheckMessage(Message message, Message deserializedMessage) + { + Assert.Equal(message.Id, deserializedMessage.Id); + Assert.Equal(message.BodyObject, deserializedMessage.BodyObject); + Assert.Equal(message.SendingGrain, deserializedMessage.SendingGrain); + Assert.Equal(message.SendingSilo, deserializedMessage.SendingSilo); + Assert.Equal(message.TargetGrain, deserializedMessage.TargetGrain); + Assert.Equal(message.TargetSilo, deserializedMessage.TargetSilo); + Assert.Equal(message.CacheInvalidationHeader.Count, deserializedMessage.CacheInvalidationHeader.Count); + Assert.Equal(message.ForwardCount, deserializedMessage.ForwardCount); + Assert.Equal(message.Direction, deserializedMessage.Direction); + Assert.Equal(message.InterfaceType, deserializedMessage.InterfaceType); + Assert.Equal(message.InterfaceVersion, deserializedMessage.InterfaceVersion); + foreach (var header in message.CacheInvalidationHeader) + { + Assert.Contains(header, deserializedMessage.CacheInvalidationHeader); + } + + Assert.Equal(message.BodyObject, deserializedMessage.BodyObject); + } + + private Message CreateTestMessage() + { + try + { + RequestContext.Set("fancy_feet", "yes"); + var message = this.messageFactory.CreateMessage("ladida", InvokeMethodOptions.None); + message.SendingGrain = GrainId.Create("test", "foo"); + message.TargetGrain = GrainId.Create("test2", "foo2"); + message.SendingSilo = SiloAddress.New(IPAddress.Loopback, 12345, 543212345); + message.TargetSilo = SiloAddress.New(IPAddress.Parse("100.200.1.2"), 12345, 543212345); + message.CacheInvalidationHeader = new() + { + new GrainAddress + { + GrainId = GrainId.Create("test", "foo"), + ActivationId = ActivationId.NewId(), + SiloAddress = SiloAddress.New(IPAddress.Parse("1.2.3.4"), 8285, 11) + }, + + new GrainAddress + { + GrainId = GrainId.Create("cow", "gertrude"), + ActivationId = ActivationId.NewId(), + SiloAddress = SiloAddress.New(IPAddress.Parse("2.2.2.22"), 1, 123456) + } + }; + return message; + } + finally + { + RequestContext.Clear(); + } + } + [Fact, TestCategory("Functional"), TestCategory("Serialization")] public void Message_SerializeHeaderTooBig() { + var buffer = new PooledBuffer(); try { // Create a ridiculously big RequestContext - var maxHeaderSize = this.fixture.Services.GetService>()!.Value.MaxMessageHeaderSize; + var maxHeaderSize = this.fixture.Services.GetService>().Value.MaxMessageHeaderSize; RequestContext.Set("big_object", new byte[maxHeaderSize + 1]); var message = this.messageFactory.CreateMessage(null, InvokeMethodOptions.None); - var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 0)); - var writer = pipe.Writer; - Assert.Throws(() => this.messageSerializer.Write(writer, message)); + Assert.Throws(() => this.messageSerializer.Write(ref buffer, message)); } finally { + buffer.Dispose(); RequestContext.Clear(); } } - [TestSuite("Functional")] - [TestProvider("None")] [Fact, TestCategory("Functional"), TestCategory("Serialization")] public void Message_SerializeBodyTooBig() { - var maxBodySize = this.fixture.Services.GetService>()!.Value.MaxMessageBodySize; + var buffer = new PooledBuffer(); + try + { + var maxBodySize = this.fixture.Services.GetService>().Value.MaxMessageBodySize; - // Create a request with a ridiculously big argument - var arg = new byte[maxBodySize + 1]; - var request = new[] { arg }; - var message = this.messageFactory.CreateMessage(request, InvokeMethodOptions.None); + // Create a request with a ridiculously big argument + var arg = new byte[maxBodySize + 1]; + var request = new[] { arg }; + var message = this.messageFactory.CreateMessage(request, InvokeMethodOptions.None); - var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 0)); - var writer = pipe.Writer; - Assert.Throws(() => this.messageSerializer.Write(writer, message)); + Assert.Throws(() => this.messageSerializer.Write(ref buffer, message)); + } + finally + { + buffer.Dispose(); + } } - [TestSuite("Functional")] - [TestProvider("None")] [Fact, TestCategory("Functional"), TestCategory("Serialization")] public void Message_DeserializeHeaderTooBig() { - var maxHeaderSize = this.fixture.Services.GetService>()!.Value.MaxMessageHeaderSize; - var maxBodySize = this.fixture.Services.GetService>()!.Value.MaxMessageBodySize; + var maxSize = this.fixture.Services.GetService>().Value.MaxMessageHeaderSize; - DeserializeFakeMessage(maxHeaderSize + 1, maxBodySize - 1); + DeserializeFakeMessage(maxSize + 1, 0); } - [TestSuite("Functional")] - [TestProvider("None")] [Fact, TestCategory("Functional"), TestCategory("Serialization")] public void Message_DeserializeBodyTooBig() { - var maxHeaderSize = this.fixture.Services.GetService>()!.Value.MaxMessageHeaderSize; - var maxBodySize = this.fixture.Services.GetService>()!.Value.MaxMessageBodySize; + var maxSize = this.fixture.Services.GetService>().Value.MaxMessageHeaderSize; - DeserializeFakeMessage(maxHeaderSize - 1, maxBodySize + 1); + DeserializeFakeMessage(0, maxSize + 1); } private void DeserializeFakeMessage(int headerSize, int bodySize) { - var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 0)); - var writer = pipe.Writer; + var buffer = new PooledBuffer(); - Span lengthFields = stackalloc byte[8]; - BinaryPrimitives.WriteInt32LittleEndian(lengthFields, headerSize); - BinaryPrimitives.WriteInt32LittleEndian(lengthFields[4..], bodySize); - writer.Write(lengthFields); - writer.FlushAsync().AsTask().GetAwaiter().GetResult(); + try + { + Span lengthFields = stackalloc byte[8]; + BinaryPrimitives.WriteInt32LittleEndian(lengthFields, headerSize); + BinaryPrimitives.WriteInt32LittleEndian(lengthFields[4..], bodySize); + buffer.Write(lengthFields); - pipe.Reader.TryRead(out var readResult); - var reader = readResult.Buffer; - Assert.Throws(() => this.messageSerializer.TryRead(ref reader, out var message)); + var reader = buffer.Slice(0); + Assert.Throws(() => this.messageSerializer.Read(in reader, headerSize, bodySize, out var message)); + } + finally + { + buffer.Dispose(); + } } private Message RoundTripMessage(Message message) { - var pipe = new Pipe(new PipeOptions(pauseWriterThreshold: 0)); - var writer = pipe.Writer; - this.messageSerializer.Write(writer, message); - writer.FlushAsync().AsTask().GetAwaiter().GetResult(); + var buffer = new PooledBuffer(); - pipe.Reader.TryRead(out var readResult); - var reader = readResult.Buffer; - var (requiredBytes, _, _) = this.messageSerializer.TryRead(ref reader, out var deserializedMessage); - Assert.Equal(0, requiredBytes); - return deserializedMessage!; + try + { + var (headerSize, bodySize) = this.messageSerializer.Write(ref buffer, message); + + var reader = buffer.Slice(); + this.messageSerializer.Read(in reader, headerSize, bodySize, out var deserializedMessage); + return deserializedMessage; + } + finally + { + buffer.Dispose(); + } } private Message RoundTripMessage( @@ -244,8 +357,6 @@ public void Message_RequestContextBeyondInitialCapacity_RoundTrips() } } - [TestSuite("BVT")] - [TestProvider("None")] [Fact, TestCategory("BVT")] public void MessageTest_CacheInvalidationHeader_RoundTripCompatibility() { @@ -340,42 +451,6 @@ public void MessageTest_CacheInvalidationHeader_RoundTripCompatibility() } } - [TestSuite("BVT")] - [TestProvider("None")] - [Fact, TestCategory("BVT")] - public void MessageTest_CacheInvalidationHeader_DeserializeCapsHeaderCount() - { - var fromNew = new List(); - for (var i = 0; i < Message.MaxCacheInvalidationHeaderEntries + 1; i++) - { - var grainId = GrainId.Create("test", i.ToString()); - fromNew.Add(new GrainAddressCacheUpdate( - GrainAddress.NewActivationAddress(SiloAddress.New(new IPEndPoint(IPAddress.Loopback, 11_000 + i), 11_000 + i), grainId), - GrainAddress.NewActivationAddress(SiloAddress.New(new IPEndPoint(IPAddress.Loopback, 22_000 + i), 22_000 + i), grainId))); - } - - using var writer1Session = _serializerSessionPool.GetSession(); - var writer = Writer.CreatePooled(writer1Session); - var message = new Message { CacheInvalidationHeader = fromNew }; - messageSerializer.WriteCacheInvalidationHeaders(ref writer, message); - writer.WriteVarUInt32(42); - writer.Commit(); - - using var reader1Session = _serializerSessionPool.GetSession(); - var reader = Reader.Create(writer.Output.AsReadOnlySequence(), reader1Session); - var toNew = messageSerializer.ReadCacheInvalidationHeaders(ref reader); - Assert.NotNull(toNew); - Assert.Equal(Message.MaxCacheInvalidationHeaderEntries, toNew.Count); - for (var i = 0; i < Message.MaxCacheInvalidationHeaderEntries; i++) - { - Assert.Equal(fromNew[i].InvalidGrainAddress, toNew[i].InvalidGrainAddress); - Assert.Equal(fromNew[i].ValidGrainAddress, toNew[i].ValidGrainAddress); - } - - Assert.Equal(42u, reader.ReadVarUInt32()); - writer.Dispose(); - } - private class MessageSerializerBackwardsCompatibilityStub { private readonly IFieldCodec _grainAddressCodec; @@ -393,9 +468,7 @@ internal List ReadCacheInvalidationHeaders(ref Reader(n); for (int i = 0; i < n; i++) { - var address = _grainAddressCodec.ReadValue(ref reader, reader.ReadFieldHeader()); - Assert.NotNull(address); - list.Add(address); + list.Add(_grainAddressCodec.ReadValue(ref reader, reader.ReadFieldHeader())); } return list; @@ -412,6 +485,12 @@ internal void WriteCacheInvalidationHeaders(ref Writer -/// Tests for the ArcBufferWriter class, which provides a high-performance buffer writer implementation -/// for Orleans' serialization system. ArcBufferWriter is a specialized buffer writer that manages -/// memory in pages and supports atomic reference counting (ARC) for efficient memory management. -/// -/// Orleans' serialization approach emphasizes: -/// - Zero-copy operations where possible through buffer slicing -/// - Efficient memory pooling to reduce GC pressure -/// - Reference counting for safe concurrent access -/// - Support for streaming scenarios with incremental writes and reads -/// -[Trait("Category", "BVT")] -[Trait("Suite", "BVT")] -[Trait("Provider", "None")] -[Trait("Area", "Serialization")] -public class ArcBufferWriterTests +namespace Orleans.Serialization.UnitTests { - private const int PageSize = ArcBufferWriter.MinimumPageSize; + [Trait("Category", "BVT")] + public class ArcBufferWriterTests + { + private const int PageSize = ArcBufferWriter.MinimumPageSize; #if NET6_0_OR_GREATER - private readonly Random _random = Random.Shared; + private readonly Random Random = Random.Shared; #else - private readonly Random _random = new Random(); + private readonly Random Random = new Random(); #endif - - /// - /// Verifies that writing data larger than a single page results in correct multi-page buffer management and correct data retrieval. - /// - [Fact] - public void MultiPageBuffer_CorrectlyHandlesLargeWritesAndRetrieval() - { - using var bufferWriter = new ArcBufferWriter(); - var randomData = new byte[PageSize * 3]; - _random.NextBytes(randomData); - int[] writeSizes = [1, 52, 125, 4096]; - var i = 0; - while (bufferWriter.Length < randomData.Length) - { - var offset = bufferWriter.Length; - var writeSize = Math.Min(randomData.Length - bufferWriter.Length, writeSizes[i++ % writeSizes.Length]); - bufferWriter.Write(randomData.AsSpan(offset, writeSize)); - } - - { - using var wholeBuffer = bufferWriter.PeekSlice(randomData.Length); - Assert.Equal(3, wholeBuffer.Pages.Count()); - Assert.Equal(3, wholeBuffer.PageSegments.Count()); - Assert.Equal(3, wholeBuffer.MemorySegments.Count()); - Assert.Equal(3, wholeBuffer.ArraySegments.Count()); - Assert.Equal(randomData, wholeBuffer.AsReadOnlySequence().ToArray()); - - { - using var newWriter = new ArcBufferWriter(); - newWriter.Write(wholeBuffer.AsReadOnlySequence()); - - Span headerBytes = stackalloc byte[8]; - var result = newWriter.Peek(in headerBytes); - Assert.True(result.Length >= headerBytes.Length); - Assert.Equal(randomData[0..headerBytes.Length], result[..headerBytes.Length].ToArray()); - var copiedData = new byte[newWriter.Length]; - newWriter.Peek(copiedData); - newWriter.AdvanceReader(copiedData.Length); - Assert.Equal(0, newWriter.Length); - Assert.Equal(randomData, copiedData); - } - - var spanCount = 0; - foreach (var span in wholeBuffer.SpanSegments) + [Theory] + [InlineData(0)] + [InlineData(17)] + [InlineData(PageSize)] + [InlineData(PageSize * 2 + 3)] + [InlineData(PageSize * 3 + 3)] + public void Peek_ReturnsEveryUnreadByteAcrossPages(int consumed) + { + using var writer = new ArcBufferWriter(); + var data = Enumerable.Range(0, PageSize * 3 + 17).Select(static i => (byte)(i % 251)).ToArray(); + writer.Write(data); + writer.AdvanceReader(consumed); + + for (var offset = 0; offset < writer.Length; offset++) { - Assert.Equal(PageSize, span.Length); - var spanArray = span.ToArray(); - Assert.Equal(spanArray, wholeBuffer.ArraySegments.Skip(spanCount).Take(1).Single().ToArray()); - Assert.Equal(spanArray, wholeBuffer.MemorySegments.Skip(spanCount).Take(1).Single().ToArray()); - Assert.Equal(spanArray, wholeBuffer.PageSegments.Skip(spanCount).Take(1).Single().Span.ToArray()); - Assert.Equal(spanArray, wholeBuffer.PageSegments.Skip(spanCount).Take(1).Single().Memory.ToArray()); - Assert.Equal(spanArray, wholeBuffer.PageSegments.Skip(spanCount).Take(1).Single().ArraySegment.ToArray()); - Assert.Equal(spanArray, wholeBuffer.AsReadOnlySequence().Slice(spanCount * PageSize, PageSize).ToArray()); - ++spanCount; + Assert.Equal(data[consumed + offset], writer.Reader.Peek(offset)); } - Assert.Equal(3, spanCount); + Assert.Throws(() => writer.Reader.Peek(-1)); + Assert.Throws(() => writer.Reader.Peek(writer.Length)); + Assert.Throws(() => writer.Reader.Peek(long.MaxValue)); } - Assert.Equal(randomData.Length, bufferWriter.Length); - - var sliceLength = Math.Min(3000, randomData.Length / 2); + [Fact] + public void Peek_UsesWrittenPageWhenLaterPagesAreReserved() { - using var peeked = bufferWriter.PeekSlice(sliceLength); - using var slice = bufferWriter.ConsumeSlice(sliceLength); - var sliceArray = slice.ToArray(); - Assert.Equal(randomData.AsSpan(0, sliceLength).ToArray(), sliceArray); - Assert.Equal(sliceArray, peeked.ToArray()); - Assert.Equal(sliceArray, peeked.AsReadOnlySequence().ToArray()); - - Assert.Equal(randomData.Length - sliceArray.Length, bufferWriter.Length); - } + using var writer = new ArcBufferWriter(); + var buffers = new List>(4); + writer.ReplenishBuffers(buffers); + var data = Enumerable.Range(0, PageSize + 17).Select(static i => (byte)(i % 251)).ToArray(); + writer.Write(data); - { - using var peeked = bufferWriter.PeekSlice(sliceLength); - using var slice = bufferWriter.ConsumeSlice(sliceLength); - var sliceArray = slice.ToArray(); - Assert.Equal(randomData.AsSpan(sliceLength, sliceLength).ToArray(), sliceArray); - Assert.Equal(sliceArray, peeked.ToArray()); - Assert.Equal(sliceArray, slice.AsReadOnlySequence().ToArray()); - - Assert.Equal(randomData.Length - sliceArray.Length * 2, bufferWriter.Length); + Assert.Equal(data[^1], writer.Reader.Peek(writer.Length - 1)); + Assert.Equal(data[PageSize], writer.Reader.Peek(PageSize)); + Assert.Equal(data[0], writer.Reader.Peek(0)); } - Assert.Equal(randomData.Length - sliceLength * 2, bufferWriter.Length); - } - - [Fact] - public void AsReadOnlySequence_SingleSegment_DoesNotAllocate() - { - using var buffer = new ArcBufferWriter(); - var data = Enumerable.Range(0, 128).Select(static i => (byte)i).ToArray(); - buffer.Write(data); - using var slice = buffer.PeekSlice(data.Length); - - var sequence = slice.AsReadOnlySequence(); - Assert.True(sequence.IsSingleSegment); - Assert.Equal(data, sequence.FirstSpan.ToArray()); - - _ = slice.AsReadOnlySequence().Length; - var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); - var length = 0L; - for (var i = 0; i < 1_000; i++) + [Fact] + public void Peek_PreservesTailWhenNewWritePageIsEmpty() { - length += slice.AsReadOnlySequence().Length; - } - - var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; - Assert.Equal(0, allocated); - Assert.Equal(data.Length * 1_000L, length); - } + using var writer = new ArcBufferWriter(); + writer.Write([1, 2, 3]); + var memory = writer.GetMemory(PageSize * 2); - [Theory] - [InlineData(0)] - [InlineData(17)] - [InlineData(PageSize)] - [InlineData(PageSize * 2 + 3)] - [InlineData(PageSize * 3 + 3)] - public void Peek_ReturnsEveryUnreadByteAcrossPages(int consumed) - { - using var writer = new ArcBufferWriter(); - var data = Enumerable.Range(0, PageSize * 3 + 17).Select(static i => (byte)(i % 251)).ToArray(); - writer.Write(data); - writer.AdvanceReader(consumed); + Assert.Equal(3, writer.Reader.Peek(2)); + Assert.Throws(() => writer.Reader.Peek(3)); - for (var offset = 0; offset < writer.Length; offset++) - { - Assert.Equal(data[consumed + offset], writer.Reader.Peek(offset)); + memory.Span[0] = 4; + writer.AdvanceWriter(1); + Assert.Equal(4, writer.Reader.Peek(3)); + Assert.Equal(3, writer.Reader.Peek(2)); } - Assert.Throws(() => writer.Reader.Peek(-1)); - Assert.Throws(() => writer.Reader.Peek(writer.Length)); - Assert.Throws(() => writer.Reader.Peek(long.MaxValue)); - } - - [Fact] - public void Peek_UsesWrittenPageWhenLaterPagesAreReserved() - { - using var writer = new ArcBufferWriter(); - var buffers = new List>(4); - writer.ReplenishBuffers(buffers); - var data = Enumerable.Range(0, PageSize + 17).Select(static i => (byte)(i % 251)).ToArray(); - writer.Write(data); - - Assert.Equal(data[^1], writer.Reader.Peek(writer.Length - 1)); - Assert.Equal(data[PageSize], writer.Reader.Peek(PageSize)); - Assert.Equal(data[0], writer.Reader.Peek(0)); - } - - [Fact] - public void Peek_PreservesTailWhenNewWritePageIsEmpty() - { - using var writer = new ArcBufferWriter(); - writer.Write([1, 2, 3]); - var memory = writer.GetMemory(PageSize * 2); - - Assert.Equal(3, writer.Reader.Peek(2)); - Assert.Throws(() => writer.Reader.Peek(3)); - - memory.Span[0] = 4; - writer.AdvanceWriter(1); - Assert.Equal(4, writer.Reader.Peek(3)); - Assert.Equal(3, writer.Reader.Peek(2)); - } - - [Fact] - public void Peek_TracksTruncationPatchingAndAppends() - { - using var writer = new ArcBufferWriter(); - var data = Enumerable.Range(0, PageSize * 3 + 17).Select(static i => (byte)(i % 251)).ToArray(); - writer.Write(data); - writer.AdvanceReader(13); - writer.Truncate(PageSize + 7); - - Assert.Equal(data[13 + PageSize + 6], writer.Reader.Peek(writer.Length - 1)); - writer.WriteAt(writer.Length - 1, [255]); - Assert.Equal(255, writer.Reader.Peek(writer.Length - 1)); - writer.Write([254, 253]); - Assert.Equal(254, writer.Reader.Peek(writer.Length - 2)); - Assert.Equal(253, writer.Reader.Peek(writer.Length - 1)); - } - - [Fact] - public void Peek_RespectsPinnedSliceEndWhenSourceTailGrows() - { - using var source = new ArcBufferWriter(); - var data = Enumerable.Range(0, PageSize + 64).Select(static i => (byte)(i % 251)).ToArray(); - source.Write(data); - using var all = source.PeekSlice(source.Length); - using var slice = all.Slice(13, PageSize + 4); - using var destination = new ArcBufferWriter(); - destination.AppendPinned(slice); - source.Write([253, 254, 255]); - - for (var offset = 0; offset < destination.Length; offset++) + [Fact] + public void Peek_TracksTruncationPatchingAndAppends() { - Assert.Equal(data[13 + offset], destination.Reader.Peek(offset)); - } + using var writer = new ArcBufferWriter(); + var data = Enumerable.Range(0, PageSize * 3 + 17).Select(static i => (byte)(i % 251)).ToArray(); + writer.Write(data); + writer.AdvanceReader(13); + writer.Truncate(PageSize + 7); - destination.AdvanceReader(PageSize); - Assert.Equal(data[PageSize + 16], destination.Reader.Peek(3)); - Assert.Throws(() => destination.Reader.Peek(4)); - } - - [Fact] - public void Peek_ObservesResetReuseAndDisposal() - { - using var writer = new ArcBufferWriter(); - var reader = writer.Reader; - writer.Write([1, 2, 3]); - writer.AdvanceReader(3); - Assert.Throws(() => reader.Peek(0)); - writer.Write([6]); - Assert.Equal(6, reader.Peek(0)); - - writer.Reset(); - Assert.Throws(() => reader.Peek(0)); - writer.Write([4, 5]); - Assert.Equal(5, reader.Peek(1)); - writer.Dispose(); - Assert.Throws(() => reader.Peek(0)); - } - - [Fact] - public void AppendPinned_WholeBufferPinsPagesAndReadsData() - { - using var source = new ArcBufferWriter(); - var data = new byte[PageSize * 3]; - _random.NextBytes(data); - source.Write(data); - - using var slice = source.PeekSlice(source.Length); - var pages = slice.Pages.ToArray(); - var referenceCounts = pages.Select(static page => page.ReferenceCount).ToArray(); - - using var destination = new ArcBufferWriter(); - destination.AppendPinned(slice); - - Assert.Equal(data.Length, destination.Length); - Assert.Equal(referenceCounts.Select(static count => count + 1), pages.Select(static page => page.ReferenceCount)); - Assert.Equal(data, ToArray(destination)); - } - - [Fact] - public void AppendPinned_PartialSliceBoundsReadsToSlice() - { - using var source = new ArcBufferWriter(); - var data = new byte[PageSize * 3]; - _random.NextBytes(data); - source.Write(data); - - using var full = source.PeekSlice(source.Length); - var offset = 123; - var length = PageSize * 2 - 456; - using var slice = full.Slice(offset, length); - - using var destination = new ArcBufferWriter(); - destination.AppendPinned(slice); - - Assert.Equal(length, destination.Length); - Assert.Equal(data.AsSpan(offset, length).ToArray(), ToArray(destination)); - - var reader = destination.Reader; - var firstRead = new byte[PageSize - offset]; - reader.Consume(firstRead); - Assert.Equal(data.AsSpan(offset, firstRead.Length).ToArray(), firstRead); - Assert.Equal(length - firstRead.Length, destination.Length); - - var remaining = new byte[destination.Length]; - reader.Consume(remaining); - Assert.Equal(data.AsSpan(offset + firstRead.Length, remaining.Length).ToArray(), remaining); - Assert.Equal(0, destination.Length); - } - - [Fact] - public void AppendPinned_DestinationOutlivesSource() - { - var source = new ArcBufferWriter(); - var data = new byte[PageSize + 17]; - _random.NextBytes(data); - source.Write(data); - - var slice = source.PeekSlice(source.Length); - var pages = slice.Pages.ToArray(); - var destination = new ArcBufferWriter(); - destination.AppendPinned(slice); - - source.Dispose(); - slice.Dispose(); - - Assert.All(pages, page => Assert.Equal(1, page.ReferenceCount)); - Assert.Equal(data, ToArray(destination)); - - destination.Dispose(); - Assert.All(pages, page => Assert.Equal(0, page.ReferenceCount)); - } - - [Fact] - public void AppendPinned_DisposeStopsAtAppendedTail() - { - using var source = new ArcBufferWriter(); - var data = new byte[PageSize * 3]; - _random.NextBytes(data); - source.Write(data); - - ArcBufferPage[] pages; - using (var all = source.PeekSlice(source.Length)) - { - pages = all.Pages.ToArray(); + Assert.Equal(data[13 + PageSize + 6], writer.Reader.Peek(writer.Length - 1)); + writer.WriteAt(writer.Length - 1, [255]); + Assert.Equal(255, writer.Reader.Peek(writer.Length - 1)); + writer.Write([254, 253]); + Assert.Equal(254, writer.Reader.Peek(writer.Length - 2)); + Assert.Equal(253, writer.Reader.Peek(writer.Length - 1)); } - using var slice = source.PeekSlice(PageSize * 2); - var destination = new ArcBufferWriter(); - destination.AppendPinned(slice); - - Assert.Equal(3, pages[0].ReferenceCount); - Assert.Equal(3, pages[1].ReferenceCount); - Assert.Equal(1, pages[2].ReferenceCount); - - destination.Dispose(); + [Fact] + public void Peek_RespectsPinnedSliceEndWhenSourceTailGrows() + { + using var source = new ArcBufferWriter(); + var data = Enumerable.Range(0, PageSize + 64).Select(static i => (byte)(i % 251)).ToArray(); + source.Write(data); + using var all = source.PeekSlice(source.Length); + using var slice = all.Slice(13, PageSize + 4); + using var destination = new ArcBufferWriter(); + destination.AppendPinned(slice); + source.Write([253, 254, 255]); - Assert.Equal(2, pages[0].ReferenceCount); - Assert.Equal(2, pages[1].ReferenceCount); - Assert.Equal(1, pages[2].ReferenceCount); - } + for (var offset = 0; offset < destination.Length; offset++) + { + Assert.Equal(data[13 + offset], destination.Reader.Peek(offset)); + } - [Fact] - public void AppendPinned_ResetReleasesPinnedPagesAndRestoresWritableState() - { - using var source = new ArcBufferWriter(); - var data = new byte[PageSize + 5]; - _random.NextBytes(data); - source.Write(data); + destination.AdvanceReader(PageSize); + Assert.Equal(data[PageSize + 16], destination.Reader.Peek(3)); + Assert.Throws(() => destination.Reader.Peek(4)); + } - using var slice = source.PeekSlice(source.Length); - var pages = slice.Pages.ToArray(); + [Fact] + public void Peek_ObservesResetReuseAndDisposal() + { + using var writer = new ArcBufferWriter(); + var reader = writer.Reader; + writer.Write([1, 2, 3]); + writer.AdvanceReader(3); + Assert.Throws(() => reader.Peek(0)); + writer.Write([6]); + Assert.Equal(6, reader.Peek(0)); - using var destination = new ArcBufferWriter(); - destination.AppendPinned(slice); - Assert.All(pages, page => Assert.Equal(3, page.ReferenceCount)); + writer.Reset(); + Assert.Throws(() => reader.Peek(0)); + writer.Write([4, 5]); + Assert.Equal(5, reader.Peek(1)); + writer.Dispose(); + Assert.Throws(() => reader.Peek(0)); + } - destination.Reset(); + [Fact] + public void TestMultiPageBuffer() + { + using var bufferWriter = new ArcBufferWriter(); + var randomData = new byte[PageSize * 3]; + Random.NextBytes(randomData); + int[] writeSizes = [1, 52, 125, 4096]; + var i = 0; + while (bufferWriter.UnconsumedLength < randomData.Length) + { + var writeSize = Math.Min(randomData.Length - bufferWriter.UnconsumedLength, writeSizes[i++ % writeSizes.Length]); + bufferWriter.Write(randomData); + } - Assert.All(pages, page => Assert.Equal(2, page.ReferenceCount)); - destination.Write([1, 2, 3]); - Assert.Equal([1, 2, 3], ToArray(destination)); - } + { + using var wholeBuffer = bufferWriter.PeekSlice(randomData.Length); + Assert.Equal(3, wholeBuffer.Pages.Count()); + Assert.Equal(3, wholeBuffer.PageSegments.Count()); + Assert.Equal(3, wholeBuffer.MemorySegments.Count()); + Assert.Equal(3, wholeBuffer.ArraySegments.Count()); + Assert.Equal(randomData, wholeBuffer.AsReadOnlySequence().ToArray()); - [Fact] - public void AppendPinned_PreventsMutatingPinnedPages() - { - using var source = new ArcBufferWriter(); - source.Write([1, 2, 3]); - using var slice = source.PeekSlice(source.Length); - - using var destination = new ArcBufferWriter(); - destination.AppendPinned(slice); - - Assert.Throws(() => destination.GetSpan(1)); - Assert.Throws(() => destination.GetMemory(1)); - Assert.Throws(() => destination.Write([4])); - Assert.Throws(() => destination.AdvanceWriter(1)); - Assert.Throws(() => destination.WriteAt(0, [4])); - Assert.Throws(() => destination.Truncate(1)); - Assert.Throws(() => destination.ReplenishBuffers(new List>(1))); - } + { + using var newWriter = new ArcBufferWriter(); + newWriter.Write(wholeBuffer.AsReadOnlySequence()); + + Span headerBytes = stackalloc byte[8]; + var result = newWriter.Peek(in headerBytes); + Assert.True(result.Length >= headerBytes.Length); + Assert.Equal(randomData[0..headerBytes.Length], result[..headerBytes.Length].ToArray()); + var copiedData = new byte[newWriter.UnconsumedLength]; + newWriter.Peek(copiedData); + newWriter.AdvanceReader(copiedData.Length); + Assert.Equal(0, newWriter.UnconsumedLength); + Assert.Equal(randomData, copiedData); + } - [Fact] - public void AppendPinned_RequiresEmptyWriter() - { - using var source = new ArcBufferWriter(); - source.Write([1, 2, 3]); - using var slice = source.PeekSlice(source.Length); - var pages = slice.Pages.ToArray(); - var referenceCounts = pages.Select(static page => page.ReferenceCount).ToArray(); + var spanCount = 0; + foreach (var span in wholeBuffer.SpanSegments) + { + Assert.Equal(PageSize, span.Length); + var spanArray = span.ToArray(); + Assert.Equal(spanArray, wholeBuffer.ArraySegments.Skip(spanCount).Take(1).Single().ToArray()); + Assert.Equal(spanArray, wholeBuffer.MemorySegments.Skip(spanCount).Take(1).Single().ToArray()); + Assert.Equal(spanArray, wholeBuffer.PageSegments.Skip(spanCount).Take(1).Single().Span.ToArray()); + Assert.Equal(spanArray, wholeBuffer.PageSegments.Skip(spanCount).Take(1).Single().Memory.ToArray()); + Assert.Equal(spanArray, wholeBuffer.PageSegments.Skip(spanCount).Take(1).Single().ArraySegment.ToArray()); + Assert.Equal(spanArray, wholeBuffer.AsReadOnlySequence().Slice(spanCount * PageSize, PageSize).ToArray()); + ++spanCount; + } - using var destination = new ArcBufferWriter(); - destination.Write([0]); + Assert.Equal(3, spanCount); + } - Assert.Throws(() => destination.AppendPinned(slice)); - Assert.Equal(referenceCounts, pages.Select(static page => page.ReferenceCount)); - } + Assert.Equal(randomData.Length, bufferWriter.UnconsumedLength); - [Fact] - public void AppendPinned_EmptySliceLeavesWriterWritable() - { - using var source = new ArcBufferWriter(); - source.Write([1, 2, 3]); - using var slice = source.PeekSlice(0); + { + using var peeked = bufferWriter.PeekSlice(3000); + using var slice = bufferWriter.ConsumeSlice(3000); + var sliceArray = slice.ToArray(); + Assert.Equal(randomData.AsSpan(0, 3000).ToArray(), sliceArray); + Assert.Equal(sliceArray, peeked.ToArray()); + Assert.Equal(sliceArray, peeked.AsReadOnlySequence().ToArray()); + + Assert.Equal(randomData.Length - sliceArray.Length, bufferWriter.UnconsumedLength); + } - using var destination = new ArcBufferWriter(); - destination.AppendPinned(slice); - destination.Write([4, 5]); + { + using var peeked = bufferWriter.PeekSlice(3000); + using var slice = bufferWriter.ConsumeSlice(3000); + var sliceArray = slice.ToArray(); + Assert.Equal(randomData.AsSpan(3000, 3000).ToArray(), sliceArray); + Assert.Equal(sliceArray, peeked.ToArray()); + Assert.Equal(sliceArray, slice.AsReadOnlySequence().ToArray()); + + Assert.Equal(randomData.Length - sliceArray.Length * 2, bufferWriter.UnconsumedLength); + } - Assert.Equal([4, 5], ToArray(destination)); - } + Assert.Equal(randomData.Length - 6000, bufferWriter.UnconsumedLength); + } - /// - /// Verifies that page reference counts and versions are managed correctly as slices are consumed and disposed. - /// - [Fact] - public void PageBufferManagement_TracksReferenceCountsAndVersions() - { - var bufferWriter = new ArcBufferWriter(); - var randomData = new byte[PageSize * 12]; - _random.NextBytes(randomData); - bufferWriter.Write(randomData); + [Fact] + public void TestMultiPageBufferManagement() + { + var bufferWriter = new ArcBufferWriter(); + var randomData = new byte[PageSize * 12]; + Random.NextBytes(randomData); + bufferWriter.Write(randomData); - var peeked = bufferWriter.PeekSlice(randomData.Length); - var pages = peeked.Pages.ToList(); - peeked.Dispose(); + var peeked = bufferWriter.PeekSlice(randomData.Length); + var pages = peeked.Pages.ToList(); + peeked.Dispose(); - var expected = pages.Select((p, i) => (p.Version, p.ReferenceCount)).ToList(); - CheckPages(pages, expected); + var expected = pages.Select((p, i) => (Version: p.Version, ReferenceCount: p.ReferenceCount)).ToList(); + CheckPages(pages, expected); - var slice = bufferWriter.ConsumeSlice(PageSize - 1); - slice.Dispose(); + var slice = bufferWriter.ConsumeSlice(PageSize - 1); + slice.Dispose(); - CheckPages(pages, expected); + CheckPages(pages, expected); - slice = bufferWriter.ConsumeSlice(1); - CheckPages(pages, expected); - slice.Dispose(); + slice = bufferWriter.ConsumeSlice(1); + CheckPages(pages, expected); + slice.Dispose(); - expected[0] = (expected[0].Version + 1, 0); - CheckPages(pages, expected); + expected[0] = (expected[0].Version + 1, 0); + CheckPages(pages, expected); - slice = bufferWriter.ConsumeSlice(PageSize); - CheckPages(pages, expected); - slice.Dispose(); + slice = bufferWriter.ConsumeSlice(PageSize); + CheckPages(pages, expected); + slice.Dispose(); - expected[1] = (expected[1].Version + 1, 0); - CheckPages(pages, expected); + expected[1] = (expected[1].Version + 1, 0); + CheckPages(pages, expected); - slice = bufferWriter.ConsumeSlice(PageSize + 1); - expected[3] = (expected[3].Version, expected[3].ReferenceCount + 1); - CheckPages(pages, expected); - slice.Dispose(); + slice = bufferWriter.ConsumeSlice(PageSize + 1); + expected[3] = (expected[3].Version, expected[3].ReferenceCount + 1); + CheckPages(pages, expected); + slice.Dispose(); - expected[2] = (expected[2].Version + 1, 0); - expected[3] = (expected[3].Version, expected[3].ReferenceCount - 1); - CheckPages(pages, expected); + expected[2] = (expected[2].Version + 1, 0); + expected[3] = (expected[3].Version, expected[3].ReferenceCount - 1); + CheckPages(pages, expected); - Assert.Equal(randomData.Length - 1 - PageSize * 3, bufferWriter.Length); + Assert.Equal(randomData.Length - 1 - PageSize * 3, bufferWriter.UnconsumedLength); - bufferWriter.Dispose(); - expected = expected.Take(3).Concat(expected.Skip(3).Select(e => (e.Version + 1, 0))).ToList(); - CheckPages(pages, expected); + bufferWriter.Dispose(); + expected = expected.Take(3).Concat(expected.Skip(3).Select(e => (e.Version + 1, 0))).ToList(); + CheckPages(pages, expected); - Assert.Throws(() => bufferWriter.Length); + Assert.Equal(0, bufferWriter.UnconsumedLength); - static void CheckPages(List pages, List<(int Version, int ReferenceCount)> expectedValues) - { - var index = 0; - foreach (var page in pages) + static void CheckPages(List pages, List<(int Version, int ReferenceCount)> expectedValues) { - var expected = expectedValues[index]; - CheckPage(page, expected.Version, expected.ReferenceCount); - ++index; + var index = 0; + foreach (var page in pages) + { + var expected = expectedValues[index]; + CheckPage(page, expected.Version, expected.ReferenceCount); + ++index; + } } - } - static void CheckPage(ArcBufferPage page, int expectedVersion, int expectedRefCount) - { - Assert.Equal(expectedVersion, page.Version); - Assert.Equal(expectedRefCount, page.ReferenceCount); + static void CheckPage(ArcBufferPage page, int expectedVersion, int expectedRefCount) + { + Assert.Equal(expectedVersion, page.Version); + Assert.Equal(expectedRefCount, page.ReferenceCount); + } } - } - /// - /// Verifies that ReplenishBuffers provides correct buffer segments for socket-like reads and that all pages are eventually freed. - /// - [Fact] - public void ReplenishBuffers_ProvidesSegmentsAndFreesPages() - { - var bufferWriter = new ArcBufferWriter(); - int[] socketReadSizes = [256, 4096, 76, 12805, 4096, 26, 8094, 12345, 1, 0, 12345]; - int[] messageReadSizes = [8, 1020, 8, 902, 8, 1203, 8, 8045, 0, 12034, 8, 1101, 8, 4096]; - var randomData = new byte[socketReadSizes.Sum()]; - _random.NextBytes(randomData); - bufferWriter.Write([0]); - var pages = new List(); - var firstSlice = bufferWriter.ConsumeSlice(1); - var firstPage = firstSlice.Pages.First(); - firstSlice.Dispose(); - - var bufferCapacity = Math.Max(16, (socketReadSizes.Max() + PageSize - 1) / PageSize + 1); - var buffers = new List>(capacity: bufferCapacity); - var consumed = new List(); - var messageReadIndex = 0; - - ReadOnlySpan socket = randomData; - foreach (var readSize in socketReadSizes) + [Fact] + public void TestReplenishBuffers() { - bufferWriter.ReplenishBuffers(buffers); + var bufferWriter = new ArcBufferWriter(); + var randomData = new byte[PageSize * 16]; + Random.NextBytes(randomData); + bufferWriter.Write([0]); + var pages = new List(); + var firstSlice = bufferWriter.ConsumeSlice(1); + var firstPage = firstSlice.Pages.First(); + firstSlice.Dispose(); - // Simulate reading from a socket. - Read(ref socket, readSize, buffers); - MaintainBufferList(buffers, readSize); - bufferWriter.AdvanceWriter(readSize); + var buffers = new List>(capacity: 16); + var consumed = new List(); + int[] socketReadSizes = [256, 4096, 76, 12805, 4096, 26, 8094, 12345, 1, 0, 12345]; + int[] messageReadSizes = [8, 1020, 8, 902, 8, 1203, 8, 8045, 0, 12034, 8, 1101, 8, 4096]; + var messageReadIndex = 0; - // Add the newly allocated pages to the list for test assertion purposes. - using (var peeked = bufferWriter.PeekSlice(bufferWriter.Length)) + ReadOnlySpan socket = randomData; + foreach (var readSize in socketReadSizes) { + bufferWriter.ReplenishBuffers(buffers); + + // Simulate reading from a socket. + Read(ref socket, readSize, buffers); + MaintainBufferList(buffers, readSize); + bufferWriter.AdvanceWriter(readSize); + + // Add the newly allocated pages to the list for test assertion purposes. + using var peeked = bufferWriter.PeekSlice(bufferWriter.UnconsumedLength); pages.AddRange(peeked.Pages.Where(p => !pages.Contains(p))); - } - // Simulate consuming the socket data. - while (bufferWriter.Length > messageReadSizes[messageReadIndex % messageReadSizes.Length]) - { - consumed.Add(bufferWriter.ConsumeSlice(messageReadSizes[messageReadIndex++ % messageReadSizes.Length])); + // Simulate consuming the socket data. + while (bufferWriter.UnconsumedLength > messageReadSizes[messageReadIndex % messageReadSizes.Length]) + { + consumed.Add(bufferWriter.ConsumeSlice(messageReadSizes[messageReadIndex++ % messageReadSizes.Length])); + } } - } - consumed.Add(bufferWriter.ConsumeSlice(bufferWriter.Length)); + consumed.Add(bufferWriter.ConsumeSlice(bufferWriter.UnconsumedLength)); - var totalReadSize = socketReadSizes.Sum(); - Assert.Equal(totalReadSize, consumed.Sum(c => c.Length)); - var consumedData = new byte[totalReadSize]; - var consumerSpan = consumedData.AsSpan(); - foreach (var buffer in consumed) - { - buffer.CopyTo(consumerSpan); - consumerSpan = consumerSpan[buffer.Length..]; - } + var totalReadSize = socketReadSizes.Sum(); + Assert.Equal(totalReadSize, consumed.Sum(c => c.Length)); + var consumedData = new byte[totalReadSize]; + var consumerSpan = consumedData.AsSpan(); + foreach (var buffer in consumed) + { + buffer.CopyTo(consumerSpan); + consumerSpan = consumerSpan[buffer.Length..]; + } - Assert.Equal(randomData[..totalReadSize], consumedData); - foreach (var buffer in consumed) - { - buffer.Dispose(); - } + Assert.Equal(randomData[..totalReadSize], consumedData); + foreach (var buffer in consumed) + { + buffer.Dispose(); + } - bufferWriter.Dispose(); + bufferWriter.Dispose(); - // Check that all pages were freed. - foreach (var page in pages) - { - Assert.Equal(0, page.ReferenceCount); - } + // Check that all pages were freed. + foreach (var page in pages) + { + Assert.Equal(0, page.ReferenceCount); + } - static void MaintainBufferList(List> buffers, int readSize) - { - while (readSize > 0) + static void MaintainBufferList(List> buffers, int readSize) { - if (buffers[0].Count <= readSize) - { - // Consume the buffer completely. - readSize -= buffers[0].Count; - buffers.RemoveAt(0); - } - else + while (readSize > 0) { - // Consume the buffer partially. - // Entries are created from array-backed pages, so Array is always populated. - buffers[0] = new(buffers[0].Array!, buffers[0].Offset + readSize, buffers[0].Count - readSize); - break; + if (buffers[0].Count <= readSize) + { + // Consume the buffer completely. + readSize -= buffers[0].Count; + buffers.RemoveAt(0); + } + else + { + // Consume the buffer partially. + buffers[0] = new(buffers[0].Array!, buffers[0].Offset + readSize, buffers[0].Count - readSize); + break; + } } } - } - static void Read(ref ReadOnlySpan socket, int readSize, List> buffers) - { - var payload = socket[..readSize]; - socket = socket[readSize..]; - var bufferIndex = 0; - while (!payload.IsEmpty) + static void Read(ref ReadOnlySpan socket, int readSize, List> buffers) { - var output = buffers[bufferIndex]; - var amount = Math.Min(output.Count, payload.Length); - payload[..amount].CopyTo(output); - payload = payload[amount..]; - ++bufferIndex; + var payload = socket[..readSize]; + socket = socket[readSize..]; + var bufferIndex = 0; + while (!payload.IsEmpty) + { + var output = buffers[bufferIndex]; + var amount = Math.Min(output.Count, payload.Length); + payload[..amount].CopyTo(output); + payload = payload[amount..]; + ++bufferIndex; + } } } - } - /// - /// Verifies that writing a buffer of a given size results in the correct reported length. - /// - [Fact] - public void WriteBuffer_UpdatesLengthCorrectly() - { - using var buffer = new ArcBufferWriter(); - var data = new byte[1024]; - _random.NextBytes(data); - buffer.Write(data); - - // Assert - Assert.Equal(data.Length, buffer.Length); - } - - /// - /// Verifies that peeking at a slice returns the correct data without consuming it. - /// - [Fact] - public void PeekSlice_ReturnsCorrectDataWithoutConsuming() - { - using var buffer = new ArcBufferWriter(); - var data = new byte[1024]; - _random.NextBytes(data); - buffer.Write(data); - - using var peeked = buffer.PeekSlice(512); - // Assert - Assert.Equal(data.AsSpan(0, 512).ToArray(), peeked.ToArray()); - } - - /// - /// Verifies that consuming a slice returns the correct data and updates the buffer length. - /// - [Fact] - public void ConsumeSlice_ReturnsCorrectDataAndUpdatesLength() - { - using var buffer = new ArcBufferWriter(); - var data = new byte[1024]; - _random.NextBytes(data); - buffer.Write(data); - - using var slice = buffer.ConsumeSlice(512); - using var subSlice = slice.Slice(256, 256); - // Assert - Assert.Equal(data.AsSpan(0, 512).ToArray(), slice.ToArray()); - Assert.Equal(data.AsSpan(256, 256).ToArray(), subSlice.ToArray()); - Assert.Equal(data.Length - slice.Length, buffer.Length); - } - - /// - /// Verifies that using a slice after it has been unpinned throws an exception. - /// - [Fact] - public void UseAfterFree_ThrowsException() - { - using var buffer = new ArcBufferWriter(); - var data = new byte[1024]; - _random.NextBytes(data); - buffer.Write(data); - - var slice = buffer.ConsumeSlice(512); - slice.Unpin(); - - // Assert - Assert.Throws(() => slice.ToArray()); - } - - /// - /// Verifies that double unpinning a slice throws, and that buffer can be reset and disposed safely. - /// - [Fact] - public void DoubleFree_ThrowsAndBufferCanBeResetAndDisposed() - { - var buffer = new ArcBufferWriter(); - var data = new byte[1024]; - _random.NextBytes(data); - buffer.Write(data); - - var slice = buffer.ConsumeSlice(512); - slice.Unpin(); - - // Assert - Assert.Throws(() => slice.Unpin()); - - Assert.Equal(512, buffer.Length); - buffer.Reset(); - Assert.Equal(0, buffer.Length); - - buffer.Dispose(); - } - - /// - /// Verifies that a new buffer is empty. - /// - [Fact] - public void NewBuffer_IsEmpty() - { - using var buffer = new ArcBufferWriter(); - // Assert - Assert.Equal(0, buffer.Length); - } - - /// - /// Verifies that writing an empty buffer does not change the buffer length. - /// - [Fact] - public void WriteEmptyBuffer_DoesNotChangeLength() - { - using var buffer = new ArcBufferWriter(); - var data = Array.Empty(); - _random.NextBytes(data); - buffer.Write(data); - - // Assert - Assert.Equal(0, buffer.Length); - } - - /// - /// Verifies that peeking at an empty buffer returns empty segments and throws when peeking past end. - /// - [Fact] - public void PeekEmptyBuffer_ReturnsEmptyAndThrowsOnOverflow() - { - using var buffer = new ArcBufferWriter(); - using var peeked = buffer.PeekSlice(0); - using var subSlice = peeked.Slice(0, 0); - Assert.Empty(peeked.Pages); - Assert.Empty(peeked.PageSegments); - Assert.Empty(peeked.ArraySegments); - Assert.Empty(peeked.MemorySegments); - - Assert.Empty(subSlice.Pages); - Assert.Empty(subSlice.PageSegments); - Assert.Empty(subSlice.ArraySegments); - Assert.Empty(subSlice.MemorySegments); - - // Assert - Assert.Equal(0, peeked.Length); - Assert.Throws(() => buffer.PeekSlice(1)); - } - - /// - /// Verifies that consuming an empty buffer returns empty segments and throws when consuming past end. - /// - [Fact] - public void ConsumeEmptyBuffer_ReturnsEmptyAndThrowsOnOverflow() - { - using var buffer = new ArcBufferWriter(); - using var slice = buffer.ConsumeSlice(0); - using var subSlice = slice.Slice(0, 0); - Assert.Empty(slice.Pages); - Assert.Empty(slice.PageSegments); - Assert.Empty(slice.ArraySegments); - Assert.Empty(slice.MemorySegments); - Assert.Equal(0, slice.AsReadOnlySequence().Length); - - Assert.Empty(subSlice.Pages); - Assert.Empty(subSlice.PageSegments); - Assert.Empty(subSlice.ArraySegments); - Assert.Empty(subSlice.MemorySegments); - Assert.Equal(0, subSlice.AsReadOnlySequence().Length); - - // Assert - Assert.Equal(0, slice.Length); - Assert.Equal(0, buffer.Length); - Assert.Throws(() => buffer.PeekSlice(1)); - Assert.Throws(() => buffer.ConsumeSlice(1)); - } - - /// - /// Verifies that disposing a slice after consuming a full page increments the page version. - /// - [Fact] - public void DisposeSliceAfterFullPageConsumption_IncrementsPageVersion() - { - using var bufferWriter = new ArcBufferWriter(); - var data = new byte[ArcBufferPagePool.MinimumPageSize + 1]; - _random.NextBytes(data); - bufferWriter.Write(data); - - // Consuming the slice will cause the writer to release (unpin) those pages. - // Since we write more than one page (MinimumPageSize), we should have at least two pages. - // The write head will sit on the second page, leaving the first free to be consumed. - var slice = bufferWriter.ConsumeSlice(ArcBufferPagePool.MinimumPageSize); - var pages = new List(slice.Pages); - - var initialVersions = pages.Select(p => p.Version).ToList(); - slice.Dispose(); - - // Assert - foreach (var page in pages.Zip(initialVersions)) + [Fact] + public void TestWritingBuffers() { - // Check that the versions have been incremented. - Assert.True(page.First.Version > page.Second); - } - } - - /// - /// Verifies that after writing and then advancing the read head, the page version is incremented as expected. - /// - [Fact] - public void PageVersionIncrementAfterWriteAndReadHeadAdvance() - { - using var bufferWriter = new ArcBufferWriter(); - var data = new byte[ArcBufferPagePool.MinimumPageSize]; - _random.NextBytes(data); - bufferWriter.Write(data); - - // Since we write exactly one page (MinimumPageSize), we should have exactly one page. - // The write head will sit on the first page, preventing it from being unpinned. - var slice = bufferWriter.ConsumeSlice(ArcBufferPagePool.MinimumPageSize); - var pages = new List(slice.Pages); - - var initialVersions = pages.Select(p => p.Version).ToList(); - slice.Dispose(); + using var buffer = new ArcBufferWriter(); + var data = new byte[1024]; + Random.NextBytes(data); + buffer.Write(data); - // Assert - foreach (var page in pages.Zip(initialVersions)) - { - // Check that the versions have NOT been incremented. - Assert.False(page.First.Version > page.Second); + // Assert + Assert.Equal(data.Length, buffer.UnconsumedLength); } - // Write one more byte, moving the write head to the second page. - bufferWriter.Write([0]); - - // Advance the read head to trigger unpinning and version increment. - bufferWriter.AdvanceReader(1); - - // Assert - foreach (var page in pages.Zip(initialVersions)) + [Fact] + public void TestPeekingAtSlices() { - // Check that the versions have NOT been incremented. - Assert.True(page.First.Version > page.Second); - } - } - - /// - /// Verifies that all operations throw ObjectDisposedException after the buffer is disposed. - /// - [Fact] - public void DisposedBuffer_ThrowsOnAllOperations() - { - var buffer = new ArcBufferWriter(); - buffer.Dispose(); - Assert.Throws(() => buffer.GetMemory(1)); - Assert.Throws(() => buffer.GetSpan(1)); - Assert.Throws(() => buffer.Write(new byte[1])); - Assert.Throws(() => buffer.PeekSlice(0)); - Assert.Throws(() => buffer.ConsumeSlice(0)); - Assert.Throws(() => buffer.AdvanceWriter(1)); - Assert.Throws(() => buffer.AdvanceReader(0)); - Assert.Throws(() => buffer.Reset()); - Assert.Throws(() => buffer.ReplenishBuffers(new List>(1))); - } - - /// - /// Verifies that double-disposing an ArcBuffer slice is safe and does not throw. - /// - [Fact] - public void DoubleDisposeArcBuffer_IsSafe() - { - using var buffer = new ArcBufferWriter(); - buffer.Write(new byte[100]); - var slice = buffer.PeekSlice(10); - slice.Dispose(); - // Should not throw - slice.Dispose(); - } - - /// - /// Verifies that resetting a disposed buffer throws ObjectDisposedException. - /// - [Fact] - public void ResetAfterDispose_Throws() - { - var buffer = new ArcBufferWriter(); - buffer.Dispose(); - Assert.Throws(() => buffer.Reset()); - } - - /// - /// Verifies that advancing the writer by a negative value throws ArgumentOutOfRangeException. - /// - [Fact] - public void AdvanceWriterNegative_Throws() - { - using var buffer = new ArcBufferWriter(); - Assert.Throws(() => buffer.AdvanceWriter(-1)); - } - - /// - /// Verifies that advancing the reader by a negative or too-large value throws ArgumentOutOfRangeException. - /// - [Fact] - public void AdvanceReaderNegativeOrTooLarge_Throws() - { - using var buffer = new ArcBufferWriter(); - buffer.Write(new byte[10]); - Assert.Throws(() => buffer.PeekSlice(11)); - Assert.Throws(() => buffer.ConsumeSlice(11)); - } - - /// - /// Verifies that calling Reset() after writing data spanning several pages returns all pages to the pool and empties the buffer. - /// - [Fact] - public void ResetReleasesAllPages_EmptiesBuffer() - { - using var buffer = new ArcBufferWriter(); - buffer.Write(new byte[ArcBufferPagePool.MinimumPageSize * 3]); - buffer.Reset(); - Assert.Equal(0, buffer.Length); - } - - /// - /// Verifies that calling Dispose() multiple times on ArcBufferWriter is safe. - /// - [Fact] - public void DisposeMultipleTimes_IsSafe() - { - var buffer = new ArcBufferWriter(); - buffer.Dispose(); - buffer.Dispose(); - } - - /// - /// Verifies that writing or getting memory/span after Dispose() throws ObjectDisposedException. - /// - [Fact] - public void WriteAfterDispose_Throws() - { - var buffer = new ArcBufferWriter(); - buffer.Dispose(); - Assert.Throws(() => buffer.Write(new byte[1])); - Assert.Throws(() => buffer.GetMemory(1)); - Assert.Throws(() => buffer.GetSpan(1)); - } - - /// - /// Verifies that pinning and unpinning a page multiple times only returns it to the pool when the reference count reaches zero. - /// - [Fact] - public void PinUnpinReferenceCounting_WorksCorrectly() - { - var page = new ArcBufferPage(ArcBufferPagePool.MinimumPageSize); - int token = page.Version; - page.Pin(token); - page.Pin(token); - Assert.Equal(2, page.ReferenceCount); - page.Unpin(token); - Assert.Equal(1, page.ReferenceCount); - page.Unpin(token); - Assert.Equal(0, page.ReferenceCount); - } + using var buffer = new ArcBufferWriter(); + var data = new byte[1024]; + Random.NextBytes(data); + buffer.Write(data); - /// - /// Verifies that unpinning a page with an incorrect version token throws InvalidOperationException. - /// - [Fact] - public void UnpinWithInvalidToken_Throws() - { - var page = new ArcBufferPage(ArcBufferPagePool.MinimumPageSize); - int token = page.Version; - page.Pin(token); - Assert.Throws(() => page.Unpin(token + 1)); - } - - /// - /// Verifies that CheckValidity throws if the reference count is zero or negative. - /// - [Fact] - public void CheckValidityWithInvalidRefCount_Throws() - { - var page = new ArcBufferPage(ArcBufferPagePool.MinimumPageSize); - int token = page.Version; - Assert.Throws(() => page.CheckValidity(token)); - } + using var peeked = buffer.PeekSlice(512); - /// - /// Verifies that disposing a slice does not affect the original buffer. - /// - [Fact] - public void SliceDispose_DoesNotAffectOriginalBuffer() - { - using var buffer = new ArcBufferWriter(); - buffer.Write(new byte[100]); - var slice = buffer.PeekSlice(50); - slice.Dispose(); - Assert.Equal(100, buffer.Length); - } + // Assert + Assert.Equal(data.AsSpan(0, 512).ToArray(), peeked.ToArray()); + } - /// - /// Verifies that UnsafeSlice does not increment the reference count. - /// - [Fact] - public void UnsafeSlice_DoesNotPinPages() - { - using var buffer = new ArcBufferWriter(); - buffer.Write(new byte[100]); - var slice = buffer.PeekSlice(100); - var page = slice.First; - int before = page.ReferenceCount; - var unsafeSlice = slice.UnsafeSlice(10, 10); - Assert.Equal(before, unsafeSlice.First.ReferenceCount); - } + [Fact] + public void TestConsumingSlices() + { + using var buffer = new ArcBufferWriter(); + var data = new byte[1024]; + Random.NextBytes(data); + buffer.Write(data); - /// - /// Verifies that copying to a span that is too small throws. - /// - [Fact] - public void CopyToWithInsufficientDestination_Throws() - { - using var buffer = new ArcBufferWriter(); - buffer.Write(new byte[100]); - var slice = buffer.PeekSlice(100); - var dest = new byte[50]; - Assert.Throws(() => slice.CopyTo(dest.AsSpan())); - } + using var slice = buffer.ConsumeSlice(512); + using var subSlice = slice.Slice(256, 256); - /// - /// Verifies that consuming more bytes than available throws. - /// - [Fact] - public void ConsumeMoreThanAvailable_Throws() - { - using var buffer = new ArcBufferWriter(); - buffer.Write(new byte[10]); - Assert.Throws(() => buffer.ConsumeSlice(20)); - } + // Assert + Assert.Equal(data.AsSpan(0, 512).ToArray(), slice.ToArray()); + Assert.Equal(data.AsSpan(256, 256).ToArray(), subSlice.ToArray()); + Assert.Equal(data.Length - slice.Length, buffer.UnconsumedLength); + } - /// - /// Verifies that Skip() advances the read head. - /// - [Fact] - public void SkipAdvancesReadHead_WorksCorrectly() - { - using var buffer = new ArcBufferWriter(); - buffer.Write(new byte[100]); - var reader = buffer.Reader; - reader.Skip(50); - Assert.Equal(50, reader.Length); - } + [Fact] + public void Peek_DoesNotReadPastPartialPinnedSlice() + { + using var source = new ArcBufferWriter(); + source.Write(Enumerable.Range(0, 32).Select(static value => (byte)value).ToArray()); + using var slice = source.PeekSlice(10); + using var buffer = new ArcBufferWriter(); + buffer.AppendPinned(slice); + Span destination = stackalloc byte[20]; - [Fact] - public void ArcBufferReader_Peek_ReadsAtOffsetWithoutAdvancing() - { - using var buffer = new ArcBufferWriter(); - var data = Enumerable.Repeat((byte)'x', PageSize - 1) - .Concat([(byte)'A', (byte)'B', (byte)'C']) - .ToArray(); - buffer.Write(data); - var reader = buffer.Reader; - reader.Skip(PageSize - 1); - - Assert.Equal((byte)'A', reader.Peek(0)); - Assert.Equal((byte)'C', reader.Peek(2)); - Assert.Equal(3, reader.Length); - Assert.Throws(() => reader.Peek(-1)); - Assert.Throws(() => reader.Peek(3)); - } + Assert.Equal(0, buffer.Peek(in destination).Length); + Assert.Equal(10, buffer.Peek(destination)); + Assert.Equal(Enumerable.Range(0, 10).Select(static value => (byte)value), destination[..10].ToArray()); + Assert.Throws(() => buffer.AdvanceReader(11)); - [Fact] - public void ArcBufferReader_TryReadTo_FindsDelimiterAcrossPageBoundary() - { - using var buffer = new ArcBufferWriter(); - var data = Enumerable.Repeat((byte)'a', PageSize) - .Concat([(byte)'\n', (byte)'b']) - .ToArray(); - buffer.Write(data); - var reader = buffer.Reader; - - Assert.True(reader.TryReadTo(out var line, (byte)'\n')); - using (line) - { - Assert.Equal(PageSize, line.Length); - Assert.All(line.ToArray(), value => Assert.Equal((byte)'a', value)); + buffer.AdvanceReader(10); + Assert.Equal(0, buffer.UnconsumedLength); } - Assert.Equal(1, reader.Length); - Span next = stackalloc byte[1]; - var peeked = reader.Peek(next); - Assert.Equal(1, peeked.Length); - Assert.Equal((byte)'b', peeked[0]); - } - - [Fact] - public void ArcBufferReader_TryReadTo_NotFoundDoesNotAdvance() - { - using var buffer = new ArcBufferWriter(); - var data = Enumerable.Range(0, PageSize + 1).Select(static i => (byte)((i % 250) + 1)).ToArray(); - buffer.Write(data); - var reader = buffer.Reader; + [Fact] + public void TestUseAfterFreeViolation() + { + using var buffer = new ArcBufferWriter(); + var data = new byte[1024]; + Random.NextBytes(data); + buffer.Write(data); - Assert.False(reader.TryReadTo(out var line, (byte)0)); + var slice = buffer.ConsumeSlice(512); + slice.Unpin(); - Assert.Equal(default, line); - Assert.Equal(data.Length, reader.Length); - } + // Assert + Assert.Throws(() => slice.ToArray()); + } - [Fact] - public void ArcBufferReader_IsNext_MatchesAcrossPageBoundary() - { - using var buffer = new ArcBufferWriter(); - var data = Enumerable.Repeat((byte)'x', PageSize - 1) - .Concat([(byte)'A', (byte)'B', (byte)'C']) - .ToArray(); - buffer.Write(data); - var reader = buffer.Reader; - reader.Skip(PageSize - 1); - - Assert.True(reader.IsNext([(byte)'A', (byte)'B'])); - Assert.Equal(3, reader.Length); - Assert.True(reader.IsNext([(byte)'A', (byte)'B'], advancePast: true)); - Assert.Equal(1, reader.Length); - Span remaining = stackalloc byte[1]; - var peeked = reader.Peek(remaining); - Assert.Equal(1, peeked.Length); - Assert.Equal((byte)'C', peeked[0]); - } + [Fact] + public void TestDoubleFreeViolation() + { + var buffer = new ArcBufferWriter(); + var data = new byte[1024]; + Random.NextBytes(data); + buffer.Write(data); - /// - /// Verifies that large pages are reused by the pool. - /// - [Fact] - public void LargePageReuse_Works() - { - var pool = ArcBufferPagePool.Shared; - var page1 = pool.Rent(ArcBufferPagePool.MinimumPageSize * 4); - int version1 = page1.Version; - page1.Pin(version1); // Pin the page - page1.Unpin(version1); // Return to pool - var page2 = pool.Rent(ArcBufferPagePool.MinimumPageSize * 4); - Assert.True(page2.Version > version1 || page2 != page1); - } + var slice = buffer.ConsumeSlice(512); + slice.Unpin(); - /// - /// Verifies that minimum size pages are reused by the pool. - /// - [Fact] - public void MinimumPageReuse_Works() - { - var pool = ArcBufferPagePool.Shared; - var page1 = pool.Rent(); - int version1 = page1.Version; - page1.Pin(version1); // Pin the page - page1.Unpin(version1); // Return to pool - var page2 = pool.Rent(); - Assert.True(page2.Version > version1 || page2 != page1); - } + // Assert + Assert.Throws(() => slice.Unpin()); - /// - /// Verifies boundary values for slicing, peeking, and consuming. - /// - [Fact] - public void BoundaryValue_SlicePeekConsume() - { - using var buffer = new ArcBufferWriter(); - var data = new byte[PageSize * 2]; - _random.NextBytes(data); - buffer.Write(data); + Assert.Equal(512, buffer.UnconsumedLength); + buffer.Reset(); + Assert.Equal(0, buffer.UnconsumedLength); - // Slice at start - using (var s = buffer.PeekSlice(0)) - { - Assert.Equal(0, s.Length); - } - using (var s = buffer.PeekSlice(1)) - { - Assert.Equal(data[0], s.ToArray()[0]); - } - using (var s = buffer.PeekSlice(data.Length)) - { - Assert.Equal(data, s.ToArray()); + buffer.Dispose(); } - // Slice at page boundary - using (var s = buffer.PeekSlice(PageSize)) + [Fact] + public void TestEmptyBuffer() { - Assert.Equal(data.Take(PageSize).ToArray(), s.ToArray()); - } - using (var s = buffer.PeekSlice(PageSize + 1)) - { - Assert.Equal(data.Take(PageSize + 1).ToArray(), s.ToArray()); - } + using var buffer = new ArcBufferWriter(); - // Consume at boundaries - using (var s = buffer.ConsumeSlice(0)) - { - Assert.Equal(0, s.Length); + // Assert + Assert.Equal(0, buffer.UnconsumedLength); } - using (var s = buffer.ConsumeSlice(1)) - { - Assert.Equal(data[0], s.ToArray()[0]); - } - using (var s = buffer.ConsumeSlice(PageSize - 1)) - { - Assert.Equal(data.Skip(1).Take(PageSize - 1).ToArray(), s.ToArray()); - } - using (var s = buffer.ConsumeSlice(PageSize)) - { - Assert.Equal(data.Skip(PageSize).Take(PageSize).ToArray(), s.ToArray()); - } - } - /// - /// Verifies that double-free and use-after-free are guarded. - /// - [Fact] - public void DoubleFree_And_UseAfterFree_Guards() - { - using var buffer = new ArcBufferWriter(); - buffer.Write(new byte[100]); - var slice = buffer.PeekSlice(50); - slice.Dispose(); - // Double dispose is safe - slice.Dispose(); - // Unpin after dispose throws - Assert.Throws(() => slice.Unpin()); - // Use after dispose throws - Assert.Throws(() => slice.ToArray()); - } - - /// - /// Verifies that memory is not leaked (reference count returns to zero) after all slices are disposed. - /// - [Fact] - public void NoMemoryLeak_ReferenceCountReturnsToZero() - { - var buffer = new ArcBufferWriter(); - buffer.Write(new byte[PageSize * 2]); - var slices = new List(); - for (int i = 0; i < 10; i++) + [Fact] + public void TestWritingEmptyBuffer() { - slices.Add(buffer.PeekSlice(PageSize)); - } - var pages = slices[0].Pages.ToList(); - foreach (var s in slices) - { - s.Dispose(); - } - foreach (var p in pages) - { - Assert.Equal(1, p.ReferenceCount); // Only the buffer's own pin remains - } - buffer.Dispose(); - foreach (var p in pages) - { - Assert.Equal(0, p.ReferenceCount); - } - } + using var buffer = new ArcBufferWriter(); + var data = new byte[0]; + Random.NextBytes(data); + buffer.Write(data); - /// - /// Verifies that slicing and peeking with zero-length and full-length works for empty and full buffers. - /// - [Fact] - public void EmptyAndFullBuffer_SlicePeek() - { - using var buffer = new ArcBufferWriter(); - using (var s = buffer.PeekSlice(0)) - { - Assert.Equal(0, s.Length); - } - buffer.Write(new byte[PageSize]); - using (var s = buffer.PeekSlice(PageSize)) - { - Assert.Equal(PageSize, s.Length); - } - using (var s = buffer.ConsumeSlice(PageSize)) - { - Assert.Equal(PageSize, s.Length); + // Assert + Assert.Equal(0, buffer.UnconsumedLength); } - Assert.Equal(0, buffer.Length); - } - /// - /// Verifies that slicing at the very end of the buffer returns an empty slice. - /// - [Fact] - public void SliceAtEnd_ReturnsEmpty() - { - using var buffer = new ArcBufferWriter(); - buffer.Write(new byte[10]); - buffer.ConsumeSlice(10).Dispose(); - using (var s = buffer.PeekSlice(0)) + [Fact] + public void TestPeekingAtEmptyBuffer() { - Assert.Equal(0, s.Length); - } - Assert.Throws(() => buffer.PeekSlice(1)); - } - - /// - /// Verifies that pin/unpin on different slices to the same page does not leak memory. - /// - [Fact] - public void MultipleSlices_SamePage_NoLeak() - { - using var buffer = new ArcBufferWriter(); - buffer.Write(new byte[PageSize]); - var s1 = buffer.PeekSlice(PageSize / 2); - var s2 = buffer.PeekSlice(PageSize / 2); - var page = s1.First; - Assert.True(page.ReferenceCount >= 2); - s1.Dispose(); - Assert.True(page.ReferenceCount >= 1); - s2.Dispose(); - Assert.Equal(1, page.ReferenceCount); // Only buffer's own pin remains - buffer.Dispose(); - Assert.Equal(0, page.ReferenceCount); - } - - private static byte[] ToArray(ArcBufferWriter writer) - { - using var slice = writer.PeekSlice(writer.Length); - return slice.ToArray(); - } + using var buffer = new ArcBufferWriter(); + using var peeked = buffer.PeekSlice(0); + using var subSlice = peeked.Slice(0, 0); - [Fact] - public void IndexOfAny_EmptyValues_ReturnsMinusOne() - { - using var writer = new ArcBufferWriter(); - var data = Enumerable.Repeat((byte)0xA5, PageSize + 1).ToArray(); - WritePages(writer, data); - - Assert.Equal(-1, writer.IndexOfAny([])); - Assert.Equal(data.Length, writer.Length); - Assert.True(writer.TryPeek(0, out var first)); - Assert.Equal((byte)0xA5, first); - } + Assert.Empty(peeked.Pages); + Assert.Empty(peeked.PageSegments); + Assert.Empty(peeked.ArraySegments); + Assert.Empty(peeked.MemorySegments); - [Fact] - public void IndexOfAny_EmptyBuffer_ReturnsMinusOne() - { - using var writer = new ArcBufferWriter(); - - Assert.Equal(-1, writer.IndexOfAny([0x3C])); - Assert.Equal(0, writer.Length); - Assert.False(writer.TryPeek(0, out _)); - } + Assert.Empty(subSlice.Pages); + Assert.Empty(subSlice.PageSegments); + Assert.Empty(subSlice.ArraySegments); + Assert.Empty(subSlice.MemorySegments); - [Theory] - [InlineData(0)] - [InlineData(2 * PageSize + 1)] - [InlineData(PageSize - 1)] - [InlineData(PageSize)] - [InlineData(PageSize + 1)] - public void IndexOfAny_FirstLastAndPageBoundaryMatches_ReturnExpectedOffset(int matchOffset) - { - using var writer = new ArcBufferWriter(); - const byte Distractor = 0xA5; - const byte Marker = 0x3C; - var data = Enumerable.Repeat(Distractor, 2 * PageSize + 2).ToArray(); - data[matchOffset] = Marker; - WritePages(writer, data); - - Assert.Equal(matchOffset, writer.IndexOfAny([Marker])); - Assert.Equal(data.Length, writer.Length); - Assert.True(writer.TryPeek(matchOffset, out var match)); - Assert.Equal(Marker, match); - if (matchOffset > 0) - { - Assert.True(writer.TryPeek(matchOffset - 1, out var before)); - Assert.Equal(Distractor, before); + // Assert + Assert.Equal(0, peeked.Length); + Assert.Throws(() => buffer.PeekSlice(1)); } - if (matchOffset + 1 < data.Length) + [Fact] + public void TestConsumingEmptyBuffer() { - Assert.True(writer.TryPeek(matchOffset + 1, out var after)); - Assert.Equal(Distractor, after); - } - } + using var buffer = new ArcBufferWriter(); + using var slice = buffer.ConsumeSlice(0); + using var subSlice = slice.Slice(0, 0); - [Fact] - public void IndexOfAny_NoMatch_ReturnsMinusOne() - { - using var writer = new ArcBufferWriter(); - const byte Distractor = 0xA5; - var data = Enumerable.Repeat(Distractor, 2 * PageSize + 2).ToArray(); - WritePages(writer, data); - - Assert.Equal(-1, writer.IndexOfAny([0x31, 0x42])); - Assert.Equal(data.Length, writer.Length); - Assert.True(writer.TryPeek(data.Length - 1, out var last)); - Assert.Equal(Distractor, last); - } + Assert.Empty(slice.Pages); + Assert.Empty(slice.PageSegments); + Assert.Empty(slice.ArraySegments); + Assert.Empty(slice.MemorySegments); + Assert.Equal(0, slice.AsReadOnlySequence().Length); - [Fact] - public void IndexOfAny_AfterAdvanceReader_ReturnsUnreadRelativeOffset() - { - using var writer = new ArcBufferWriter(); - const byte Marker = 0x3C; - var data = Enumerable.Repeat((byte)0xA5, 2 * PageSize + 2).ToArray(); - var consumedPrefixLength = PageSize + 1; - var markerOffset = consumedPrefixLength + 6; - data[markerOffset] = Marker; - WritePages(writer, data); - writer.AdvanceReader(consumedPrefixLength); - - Assert.Equal(6, writer.IndexOfAny([Marker])); - Assert.Equal(data.Length - consumedPrefixLength, writer.Length); - Assert.True(writer.TryPeek(6, out var match)); - Assert.Equal(Marker, match); - } + Assert.Empty(subSlice.Pages); + Assert.Empty(subSlice.PageSegments); + Assert.Empty(subSlice.ArraySegments); + Assert.Empty(subSlice.MemorySegments); + Assert.Equal(0, subSlice.AsReadOnlySequence().Length); - [Fact] - public void IndexOfAny_MultipleNeedles_ReturnsEarliestBufferPosition() - { - using var writer = new ArcBufferWriter(); - const byte EarlierMarker = 0x31; - const byte LaterMarker = 0x42; - var data = Enumerable.Repeat((byte)0xA5, 2 * PageSize + 2).ToArray(); - var earlierOffset = PageSize - 1; - var laterOffset = PageSize + 1; - data[earlierOffset] = EarlierMarker; - data[laterOffset] = LaterMarker; - WritePages(writer, data); - - Assert.Equal(earlierOffset, writer.IndexOfAny([LaterMarker, EarlierMarker])); - Assert.Equal(data.Length, writer.Length); - Assert.True(writer.TryPeek(earlierOffset, out var earlier)); - Assert.Equal(EarlierMarker, earlier); - Assert.True(writer.TryPeek(laterOffset, out var later)); - Assert.Equal(LaterMarker, later); - } + // Assert + Assert.Equal(0, slice.Length); + Assert.Equal(0, buffer.UnconsumedLength); + Assert.Throws(() => buffer.PeekSlice(1)); + Assert.Throws(() => buffer.ConsumeSlice(1)); + } - [Fact] - public void IndexOfAny_AfterDispose_ThrowsObjectDisposedException() - { - var writer = new ArcBufferWriter(); - writer.Dispose(); + [Fact] + public void TestDisposalReturnsPagesToPoolAndIncrementsVersion() + { + using var bufferWriter = new ArcBufferWriter(); + var data = new byte[ArcBufferWriter.MinimumPageSize * 2]; + Random.NextBytes(data); + bufferWriter.Write(data); - var exception = Assert.Throws(() => writer.IndexOfAny([])); - Assert.Equal(nameof(ArcBufferWriter), exception.ObjectName); - } + var slice = bufferWriter.ConsumeSlice(ArcBufferWriter.MinimumPageSize); + var pages = new List(slice.Pages); - private static void WritePages(ArcBufferWriter writer, ReadOnlySpan data) - { - IBufferWriter output = writer; - var offset = 0; - while (offset < data.Length) - { - var count = Math.Min(PageSize, data.Length - offset); - var destination = output.GetSpan(); - Assert.True(destination.Length >= count); - data.Slice(offset, count).CopyTo(destination); - output.Advance(count); - offset += count; + var initialVersions = pages.Select(p => p.Version).ToList(); + slice.Dispose(); + + // Assert + foreach (var page in pages.Zip(initialVersions)) + { + // Check that the versions have been incremented. + Assert.True(page.First.Version > page.Second); + } } } } diff --git a/test/Orleans.Serialization.UnitTests/PooledBufferTests.cs b/test/Orleans.Serialization.UnitTests/PooledBufferTests.cs index f5272a5ccc5..e5f01b51989 100644 --- a/test/Orleans.Serialization.UnitTests/PooledBufferTests.cs +++ b/test/Orleans.Serialization.UnitTests/PooledBufferTests.cs @@ -10,19 +10,19 @@ namespace Orleans.Serialization.UnitTests { /// /// Tests for Orleans' PooledBuffer implementation. - /// + /// /// PooledBuffer is a high-performance buffer management system that: /// - Uses ArrayPool to minimize allocations and GC pressure /// - Supports efficient slicing operations without copying /// - Handles large data through segmented storage /// - Provides zero-copy access to buffer contents - /// + /// /// Key features tested: /// - Large buffer handling (multi-megabyte) /// - Slicing operations at various offsets /// - Memory safety and bounds checking /// - Proper cleanup and return to pool - /// + /// /// This infrastructure is critical for Orleans' serialization performance, /// especially when handling large object graphs or streaming scenarios. /// @@ -305,14 +305,12 @@ public void PooledBuffer_SliceEnumerators_OffsetCrossSegment_Correctness() Assert.Equal(length, spanPos); Assert.Equal(expected, spanConcat); - // Act & Assert: MemoryEnumerator var memConcat = new byte[length]; - int memPos = 0; + var memPos = 0; foreach (var mem in slice.MemorySegments) { - var span = mem.Span; - span.CopyTo(memConcat.AsSpan(memPos)); - memPos += span.Length; + mem.Span.CopyTo(memConcat.AsSpan(memPos)); + memPos += mem.Length; } Assert.Equal(length, memPos); Assert.Equal(expected, memConcat); @@ -356,14 +354,12 @@ public void PooledBuffer_SliceEnumerators_OffsetAfterFirstSegment_CoversPosition Assert.Equal(length, spanPos); Assert.Equal(expected, spanConcat); - // Act & Assert: MemoryEnumerator var memConcat = new byte[length]; - int memPos = 0; + var memPos = 0; foreach (var mem in slice.MemorySegments) { - var span = mem.Span; - span.CopyTo(memConcat.AsSpan(memPos)); - memPos += span.Length; + mem.Span.CopyTo(memConcat.AsSpan(memPos)); + memPos += mem.Length; } Assert.Equal(length, memPos); Assert.Equal(expected, memConcat); diff --git a/test/TestInfrastructure/Orleans.TestingHost.Tests/InMemoryTransportTests.cs b/test/TestInfrastructure/Orleans.TestingHost.Tests/InMemoryTransportTests.cs new file mode 100644 index 00000000000..c1e8ff0f193 --- /dev/null +++ b/test/TestInfrastructure/Orleans.TestingHost.Tests/InMemoryTransportTests.cs @@ -0,0 +1,177 @@ +using System.Net; +using System.IO.Pipelines; +using Microsoft.Extensions.Logging.Abstractions; +using Orleans.Connections.Transport; +using Orleans.Serialization.Buffers; +using Orleans.TestingHost.InMemoryTransport; +using TestExtensions; +using Xunit; + +namespace Orleans.TestingHost.Tests; + +[TestCategory("BVT")] +public class InMemoryTransportTests +{ + [Fact] + public void TestClusterOptions_DefaultConnectionTransport_IsInMemory() + { + Assert.Equal(ConnectionTransportType.InMemory, new TestClusterOptions().ConnectionTransport); + } + + [Fact] + public async Task ConnectorWaitsForListenerRegistration() + { + var hub = new InMemoryTransportConnectionHub(); + var endpoint = new IPEndPoint(IPAddress.Loopback, 12345); + var connector = new InMemoryTransportConnector(hub, NullLoggerFactory.Instance); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var connectTask = connector.CreateAsync(endpoint, timeout.Token).AsTask(); + await Task.Delay(TimeSpan.FromMilliseconds(50), timeout.Token); + Assert.False(connectTask.IsCompleted); + + var listener = new InMemoryTransportListener("test", endpoint.ToString(), hub); + await listener.BindAsync(timeout.Token); + var acceptTask = listener.AcceptAsync(timeout.Token).AsTask(); + + await using var outbound = await connectTask; + await using var inbound = Assert.IsType(await acceptTask); + + await listener.DisposeAsync(); + } + + [Fact] + public async Task CanceledConnectIsNotAccepted() + { + var hub = new InMemoryTransportConnectionHub(); + var endpoint = new IPEndPoint(IPAddress.Loopback, 12346); + var connector = new InMemoryTransportConnector(hub, NullLoggerFactory.Instance); + await using var listener = new InMemoryTransportListener("test", endpoint.ToString(), hub); + await listener.BindAsync(TestContext.Current.CancellationToken); + using var connectCancellation = new CancellationTokenSource(); + + var connectTask = connector.CreateAsync(endpoint, connectCancellation.Token).AsTask(); + connectCancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => connectTask); + + using var acceptCancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + await Assert.ThrowsAnyAsync(() => listener.AcceptAsync(acceptCancellation.Token).AsTask()); + } + + [Fact] + public async Task CompletedPipeDeliversFinalBuffer() + { + var input = new Pipe(); + var unusedOutput = new Pipe(); + await input.Writer.WriteAsync(new byte[] { 1, 2, 3 }, TestContext.Current.CancellationToken); + await input.Writer.CompleteAsync(); + await using var transport = new InMemoryMessageTransport( + new TestDuplexPipe(input.Reader, unusedOutput.Writer), + NullLogger.Instance); + transport.Start(); + var request = new TestReadRequest(3); + + Assert.True(transport.EnqueueRead(request)); + + Assert.Equal( + [1, 2, 3], + await request.Completion.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CompletedPeerFailsActiveAndQueuedWrites() + { + var input = new Pipe(); + var output = new Pipe(); + await output.Reader.CompleteAsync(); + await using var transport = new InMemoryMessageTransport( + new TestDuplexPipe(input.Reader, output.Writer), + NullLogger.Instance); + using var active = new TestWriteRequest([1]); + using var queued = new TestWriteRequest([2]); + Assert.True(transport.EnqueueWrite(active)); + Assert.True(transport.EnqueueWrite(queued)); + transport.Start(); + + await Assert.ThrowsAsync( + () => active.Completion.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + () => queued.Completion.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); + Assert.Equal(1, active.CompletionCount); + Assert.Equal(1, queued.CompletionCount); + } + + [Fact] + public async Task ListenerWithoutEndpoint_IsDisabledAndNotRegistered() + { + var hub = new InMemoryTransportConnectionHub(); + await using var listener = new InMemoryTransportListener("gateway", endpointValue: null, hub); + + Assert.False(listener.IsValid); + await listener.BindAsync(TestContext.Current.CancellationToken); + + using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + await Assert.ThrowsAnyAsync( + () => hub.GetConnectionListenerFactoryAsync("missing", cancellation.Token).AsTask()); + } + + private sealed class TestDuplexPipe(PipeReader input, PipeWriter output) : IDuplexPipe + { + public PipeReader Input { get; } = input; + public PipeWriter Output { get; } = output; + } + + private sealed class TestReadRequest(int length) : ReadRequest + { + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task Completion => _completion.Task; + + public override bool OnRead(ArcBufferReader buffer) + { + if (buffer.Length < length) + { + return false; + } + + var result = new byte[length]; + buffer.Consume(result); + _completion.TrySetResult(result); + return true; + } + + public override void OnError(Exception error) => _completion.TrySetException(error); + public override void OnCanceled() => _completion.TrySetCanceled(); + } + + private sealed class TestWriteRequest : WriteRequest, IDisposable + { + private readonly ArcBufferWriter _buffer = new(); + private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _completionCount; + + public TestWriteRequest(ReadOnlySpan bytes) + { + _buffer.Write(bytes); + Buffers = _buffer.Reader; + } + + public Task Completion => _completion.Task; + public int CompletionCount => Volatile.Read(ref _completionCount); + + public override void SetResult() + { + Interlocked.Increment(ref _completionCount); + _completion.SetResult(); + } + + public override void SetException(Exception error) + { + Interlocked.Increment(ref _completionCount); + _completion.SetException(error); + } + + public void Dispose() => _buffer.Dispose(); + } + +} diff --git a/test/TestInfrastructure/Orleans.TestingHost.Tests/UnixSocketTransportListenerTests.cs b/test/TestInfrastructure/Orleans.TestingHost.Tests/UnixSocketTransportListenerTests.cs new file mode 100644 index 00000000000..aff980bd0c8 --- /dev/null +++ b/test/TestInfrastructure/Orleans.TestingHost.Tests/UnixSocketTransportListenerTests.cs @@ -0,0 +1,118 @@ +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Orleans.TestingHost.UnixSocketTransport; +using TestExtensions; +using Xunit; + +namespace Orleans.TestingHost.Tests; + +[TestCategory("BVT")] +public class UnixSocketTransportListenerTests +{ + [Fact] + public async Task DisposeRemovesSocketFileAndIsIdempotent() + { + if (!Socket.OSSupportsUnixDomainSockets) + { + throw Xunit.Sdk.SkipException.ForSkip("Unix domain sockets are not supported."); + } + + var path = Path.Combine(Path.GetTempPath(), $"orleans-{Guid.NewGuid():N}.sock"); + var listenerName = "test"; + var options = new StaticOptionsMonitor( + listenerName, + new UnixDomainSocketMessageTransportListenerOptions { Path = path }); + var listener = new UnixDomainSocketMessageTransportListener(listenerName, options, NullLoggerFactory.Instance); + + try + { + await listener.BindAsync(TestContext.Current.CancellationToken); + Assert.True(File.Exists(path)); + } + finally + { + await listener.DisposeAsync(); + } + + await listener.DisposeAsync(); + await listener.UnbindAsync(TestContext.Current.CancellationToken); + Assert.False(File.Exists(path)); + } + + [Fact] + public async Task UnbindCompletesPendingAccept() + { + if (!Socket.OSSupportsUnixDomainSockets) + { + throw Xunit.Sdk.SkipException.ForSkip("Unix domain sockets are not supported."); + } + + var path = Path.Combine(Path.GetTempPath(), $"orleans-{Guid.NewGuid():N}.sock"); + var listenerName = "test"; + var options = new StaticOptionsMonitor( + listenerName, + new UnixDomainSocketMessageTransportListenerOptions { Path = path }); + await using var listener = new UnixDomainSocketMessageTransportListener(listenerName, options, NullLoggerFactory.Instance); + await listener.BindAsync(TestContext.Current.CancellationToken); + var acceptTask = listener.AcceptAsync(TestContext.Current.CancellationToken).AsTask(); + + await listener.UnbindAsync(TestContext.Current.CancellationToken); + + Assert.Null(await acceptTask.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); + Assert.False(File.Exists(path)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AcceptAfterShutdownReturnsNull(bool dispose) + { + if (!Socket.OSSupportsUnixDomainSockets) + { + throw Xunit.Sdk.SkipException.ForSkip("Unix domain sockets are not supported."); + } + + var path = Path.Combine(Path.GetTempPath(), $"orleans-{Guid.NewGuid():N}.sock"); + var options = new StaticOptionsMonitor( + "test", + new UnixDomainSocketMessageTransportListenerOptions { Path = path }); + await using var listener = new UnixDomainSocketMessageTransportListener("test", options, NullLoggerFactory.Instance); + await Assert.ThrowsAsync( + () => listener.AcceptAsync(TestContext.Current.CancellationToken).AsTask()); + await listener.BindAsync(TestContext.Current.CancellationToken); + var pendingAccept = listener.AcceptAsync(TestContext.Current.CancellationToken).AsTask(); + + if (dispose) + { + await listener.DisposeAsync(); + } + else + { + await listener.UnbindAsync(TestContext.Current.CancellationToken); + } + + Assert.Null(await pendingAccept.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken)); + Assert.Null(await listener.AcceptAsync(TestContext.Current.CancellationToken)); + Assert.False(File.Exists(path)); + } + + private sealed class StaticOptionsMonitor(string name, TOptions value) : IOptionsMonitor + { + public TOptions CurrentValue => value; + + public TOptions Get(string? requestedName) => + requestedName == name ? value : throw new InvalidOperationException($"No options are configured for '{requestedName}'."); + + public IDisposable OnChange(Action listener) => NoopDisposable.Instance; + } + + private sealed class NoopDisposable : IDisposable + { + public static NoopDisposable Instance { get; } = new(); + + public void Dispose() + { + } + } +}