diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md
index 32ec98884..e9522e4f2 100644
--- a/docs/ReleaseNotes.md
+++ b/docs/ReleaseNotes.md
@@ -8,10 +8,13 @@ Current package versions:
## Unreleased
+IMPORTANT: for AMR users, this changes the default protocol to RESP3. In some cases, this may require code changes. Please see [this topic](https://stackexchange.github.io/StackExchange.Redis/Resp3) for more information.
+
- Detect server-mode correctly on Valkey 8+ instances ([#3050 by @wipiano](https://github.com/StackExchange/StackExchange.Redis/pull/3050))
- Add Redis 8.8 stream negative acknowledgements (`XNACK`) ([#3058 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3058))
- Update experimental `GCRA` APIs and wire protocol terminology from "requests" to "tokens", to match server change ([#3051 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3051))
- Add experimental `Aggregate.Count` support for sorted-set combination operations against Redis 8.8 ([#3059 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3059))
+- Prefer RESP3 and avoid opening a separate subscription connection for Azure Managed Redis endpoints ([#3067 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3067))
## 2.12.14
diff --git a/docs/Resp3.md b/docs/Resp3.md
index 126b460f4..6433c168d 100644
--- a/docs/Resp3.md
+++ b/docs/Resp3.md
@@ -2,35 +2,80 @@
RESP2 and RESP3 are evolutions of the Redis protocol, with RESP3 existing from Redis server version 6 onwards (v7.2+ for Redis Enterprise). The main differences are:
-1. RESP3 can carry out-of-band / "push" messages on a single connection, where-as RESP2 requires a separate connection for these messages
-2. RESP3 can (when appropriate) convey additional semantic meaning about returned payloads inside the same result structure
-3. Some commands (see [this topic](https://github.com/redis/redis-doc/issues/2511)) return different result structures in RESP3 mode; for example a flat interleaved array might become a jagged array
+1. RESP3 can carry out-of-band / "push" messages on a single connection, where-as RESP2 requires a separate connection for out-of-band (pub/sub) messages
+ - this single connection can be of huge benefit in high-usage servers, as it halves the number of connections required
+2. RESP3 supports *additional* out-of-band messages that cannot be expressed in RESP2, which allows advanced features such as "smart client handoffs" (a family of
+ server maintenance notifications)
+ - these features (not yet implemented in SE.Redis) allow for greater stability in complex deployments
+3. RESP3 can (when appropriate) convey additional semantic meaning about returned payloads inside the same result structure
+ - this is *mostly* relevant to client libraries that do not explicitly interpret the results before exposing to the user, so this does not directly impact SE.Redis itself,
+ but it is relevant to consumers of SE.Redis that use Lua scripts or ad-hoc commands
-For most people, #1 is the main reason to consider RESP3, as in high-usage servers - this can halve the number of connections required.
-This is particularly useful in hosted environments where the number of inbound connections to the server is capped as part of a service plan.
-Alternatively, where users are currently choosing to disable the out-of-band connection to achieve this, they may now be able to re-enable this
-(for example, to receive server maintenance notifications) *without* incurring any additional connection overhead.
+For many users, using RESP3 is a "no-brainer" - it offers significant benefits with no real downsides. However, there are some important things to be aware of, and some
+migration work that may be required. In particular, some commands *return different result structures* in RESP3 mode; for example a jagged (nested) array might become a "map"
+(essentially an interleaved flat array). SE.Redis has been updated to handle these cases transparently, but if you are using `Execute[Async]` or `ScriptEvaluate[Async]` (or if
+you are using an additional library that issues ad-hoc commands or scripts on your behalf) you may need to update your processing code to compensate for this. This is discussed more below.
-Because of the significance of #3 (and to avoid breaking your code), the library does not currently default to RESP3 mode. This must be enabled explicitly
-via `ConfigurationOptions.Protocol` or by adding `,protocol=resp3` (or `,protocol=3`) to the configuration string.
+# Enabling RESP3
----
+RESP2 and RESP3 are both supported options (if the server does not support RESP3, RESP2 will always be used). To make full use of the benefits of RESP3,
+the library is moving in the direction of *preferring* RESP3. The default behaviour is:
-#3 is a critical one - the library *should* already handle all documented commands that have revised results in RESP3, but if you're using
-`Execute[Async]` to issue ad-hoc commands, you may need to update your processing code to compensate for this, ideally using detection to handle
-*either* format so that the same code works in both REP2 and RESP3. Since the impacted commands are handled internally by the library, in reality
-this should not usually present a difficulty.
+| Library version | Endpoint | Default protocol
+|-------------------------|-----------------------------------------------------------------|-
+| < 2.13 | (any) | RESP2
+| >= 2.13 and < 3.0 | (non-AMR) | RESP2
+| >= 2.13 and < 3.0 | [AMR](https://azure.microsoft.com/products/managed-redis) | RESP3
+| > 3.0† | (any) | RESP3
-The minor (#2) and major (#3) differences to results are only visible to your code when using:
+† = planned
+
+You can override this behaviour by setting the `protocol` option in the connection string, or by setting the `ConfigurationOptions.Protocol` property:
+
+```csharp
+var options = ConfigurationOptions.Parse("someserver");
+options.Protocol = RedisProtocol.Resp3; // or .Resp2
+var muxer = await ConnectionMultiplexer.ConnectAsync(options);
+```
+
+or
+
+```csharp
+var options = ConfigurationOptions.Parse("someserver,protocol=resp3"); // or =resp2
+var muxer = await ConnectionMultiplexer.ConnectAsync(options);
+```
+
+You can use this configuration to *explicitly enable* RESP3 on earlier library versions, or to *explicitly disable* RESP3 on later versions, if you encounter issues.
+
+# Handling RESP3
+
+For most users, *no additional work will be required*, or the additional work may be limited to updating libraries; for example, For example, [NRedisStack](https://www.nuget.org/packages/NRedisStack/)
+now fully supports RESP3 for the commands it exposes (search, json, time-series, etc).
+
+Scenarios impacted by RESP3 include:
- Lua scripts invoked via the `ScriptEvaluate[Async](...)` or related APIs, that either:
- Uses the `redis.setresp(3)` API and returns a value from `redis.[p]call(...)`
- Returns a value that satisfies the [LUA to RESP3 type conversion rules](https://redis.io/docs/manual/programmability/lua-api/#lua-to-resp3-type-conversion)
-- Ad-hoc commands (in particular: *modules*) that are invoked via the `Execute[Async](string command, ...)` API
+- Ad-hoc commands that are invoked via the `Execute[Async](string command, ...)` API
+
+This delta is *especially* pronounced for some of the "modules" in Redis, even those that now ship by default in OSS Redis, including:
+- "search" (`FT.SEARCH`, `FT.AGGREGATE`, etc.)
+- "time-series" (`TS.RANGE`, etc.)
+- "json" (`JSON.NUMINCRBY`, etc.)
+
+Note that NRedisStack wraps most of these common modules, and has been updated to understand RESP3; if you are using these modules via NRedisStack, you should update to the latest version; if
+you are using these modules via ad-hoc commands, you may need to update your processing code to compensate for this, or consider using NRedisStack instead, which will handle the RESP3 conversion for you.
-...both which return `RedisResult`. **If you are not using these APIs, you should not need to do anything additional.**
+This leaves a small category of users who are currently using the `RedisResult` type directly (via `Execute[Async](...)` or `ScriptEvaluate[Async](...)`).
-Historically, you could use the `RedisResult.Type` property to query the type of data returned (integer, string, etc). In particular:
+## Impact on RedisResult
+
+Firstly, note that it is possible that the *structure* of the data changes between RESP2 and RESP3; for example, a jagged array might become a map, or a single string value might become an array. You will
+need to identify these changes (typically via integration tests) and update your code accordingly, ideally with detection code to handle *either* structure so that the same code works in both REP2 and RESP3.
+
+This is usually combined by using the `RedisResult.Resp3Type` property to query the type of data returned (integer, string, etc). Historically, you could use the `RedisResult.Type` property to query the type of data returned (integer, string, etc).
+With RESP3, this is extended:
- Two new properties are added: `RedisResult.Resp2Type` and `RedisResult.Resp3Type`
- The `Resp3Type` property exposes the new semantic data (when using RESP3) - for example, it can indicate that a value is a double-precision number, a boolean, a map, etc (types that did not historically exist)
@@ -42,4 +87,7 @@ Possible changes required due to RESP3:
1. To prevent build warnings, replace usage of `ResultType.MultiBulk` with `ResultType.Array`, and usage of `RedisResult.Type` with `RedisResult.Resp2Type`
2. If you wish to exploit the additional semantic data when enabling RESP3, use `RedisResult.Resp3Type` where appropriate
-3. If you are enabling RESP3, you must verify whether the commands you are using can give different result shapes on RESP3 connections
\ No newline at end of file
+3. If you are enabling RESP3, you must verify whether the commands you are using can give different result shapes on RESP3 connections
+
+An example of the types of changes required may be seen in the [NRedisStack #471](https://github.com/redis/NRedisStack/pull/471) pull-request, which updates result processing for multiple modules
+(and changes the integration tests to run on RESP2 and RESP3 separately).
diff --git a/src/StackExchange.Redis/Configuration/AzureManagedRedisOptionsProvider.cs b/src/StackExchange.Redis/Configuration/AzureManagedRedisOptionsProvider.cs
index 06656b608..7b36b0c03 100644
--- a/src/StackExchange.Redis/Configuration/AzureManagedRedisOptionsProvider.cs
+++ b/src/StackExchange.Redis/Configuration/AzureManagedRedisOptionsProvider.cs
@@ -1,7 +1,6 @@
using System;
using System.Net;
using System.Threading.Tasks;
-using StackExchange.Redis.Maintenance;
namespace StackExchange.Redis.Configuration
{
@@ -54,9 +53,15 @@ private bool IsHostInDomains(string hostName, string[] domains)
///
public override Task AfterConnectAsync(ConnectionMultiplexer muxer, Action log)
- => AzureMaintenanceEvent.AddListenerAsync(muxer, log);
+ => Task.CompletedTask;
///
public override bool GetDefaultSsl(EndPointCollection endPoints) => true;
+
+ ///
+ public override RedisProtocol? Protocol => RedisProtocol.Resp3; // prefer RESP3 on AMR
+
+ ///
+ public override string ConfigurationChannel => ""; // disable on AMR
}
}
diff --git a/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs b/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs
index e4fa25891..f560c8ce4 100644
--- a/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs
+++ b/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs
@@ -259,6 +259,11 @@ protected virtual string GetDefaultClientName() =>
///
public virtual bool SetClientLibrary => true;
+ ///
+ /// Gets the preferred protocol to use for the connection.
+ ///
+ public virtual RedisProtocol? Protocol => null;
+
///
/// Tries to get the RoleInstance Id if Microsoft.WindowsAzure.ServiceRuntime is loaded.
/// In case of any failure, swallows the exception and returns null.
diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs
index 641fccc95..58a281ddb 100644
--- a/src/StackExchange.Redis/ConfigurationOptions.cs
+++ b/src/StackExchange.Redis/ConfigurationOptions.cs
@@ -1169,13 +1169,18 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown)
///
/// Specify the redis protocol type.
///
- public RedisProtocol? Protocol { get; set; }
+ public RedisProtocol? Protocol
+ {
+ get => field ?? Defaults.Protocol;
+ set;
+ }
internal bool TryResp3()
{
+ var protocol = Protocol;
// note: deliberately leaving the IsAvailable duplicated to use short-circuit
- // if (Protocol is null)
+ // if (protocol is null)
// {
// // if not specified, lean on the server version and whether HELLO is available
// return new RedisFeatures(DefaultVersion).Resp3 && CommandMap.IsAvailable(RedisCommand.HELLO);
@@ -1187,7 +1192,7 @@ internal bool TryResp3()
// edge case in the library itself, the break is still visible to external callers via Execute[Async]; with an
// abundance of caution, we are therefore making RESP3 explicit opt-in only for now; we may revisit this in a major
{
- return Protocol.GetValueOrDefault() >= RedisProtocol.Resp3 && CommandMap.IsAvailable(RedisCommand.HELLO);
+ return protocol.GetValueOrDefault() >= RedisProtocol.Resp3 && CommandMap.IsAvailable(RedisCommand.HELLO);
}
}
diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs
index 36f459f14..9590720c2 100644
--- a/src/StackExchange.Redis/ConnectionMultiplexer.cs
+++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs
@@ -1370,10 +1370,11 @@ internal void GetStatus(ILogger? log)
private void ActivateAllServers(ILogger? log)
{
+ bool hasSubscriptions = GetSubscriptionsCount() != 0;
foreach (var server in GetServerSnapshot())
{
server.Activate(ConnectionType.Interactive, log);
- if (server.SupportsSubscriptions && !server.KnowOrAssumeResp3())
+ if (hasSubscriptions && server.SupportsSubscriptions && !server.KnowOrAssumeResp3())
{
// Intentionally not logging the sub connection
server.Activate(ConnectionType.Subscription, null);
diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
index ab058de62..983182101 100644
--- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
+++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
@@ -1 +1,4 @@
#nullable enable
+override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.ConfigurationChannel.get -> string!
+override StackExchange.Redis.Configuration.AzureManagedRedisOptionsProvider.Protocol.get -> StackExchange.Redis.RedisProtocol?
+virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.Protocol.get -> StackExchange.Redis.RedisProtocol?
diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs
index 0da791d3c..129cfbfed 100644
--- a/src/StackExchange.Redis/ServerEndPoint.cs
+++ b/src/StackExchange.Redis/ServerEndPoint.cs
@@ -107,7 +107,6 @@ public int Databases
public bool IsConnecting => interactive?.IsConnecting == true;
public bool IsConnected => interactive?.IsConnected == true;
public bool IsSubscriberConnected => KnowOrAssumeResp3() ? IsConnected : subscription?.IsConnected == true;
-
public bool KnowOrAssumeResp3()
{
var protocol = interactive?.Protocol;
@@ -627,7 +626,7 @@ internal bool IsSelectable(RedisCommand command, bool allowDisconnected = false)
{
// Until we've connected at least once, we're going to have a DidNotRespond unselectable reason present
var bridge = unselectableReasons == 0 || (allowDisconnected && unselectableReasons == UnselectableFlags.DidNotRespond)
- ? GetBridge(command, false)
+ ? GetBridge(command, true)
: null;
return bridge != null && (allowDisconnected || bridge.IsConnected);
diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs
index 6626eda42..a25f34142 100644
--- a/tests/StackExchange.Redis.Tests/ConfigTests.cs
+++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs
@@ -158,6 +158,26 @@ public void ConfigurationOptionsDefaultForAzureManagedRedis(string hostAndPort,
Assert.Equal(sslShouldBeEnabled, options.Ssl);
}
+ [Theory]
+ // azure managed redis, no overrides
+ [InlineData("contoso.redis.azure.net:10000", RedisProtocol.Resp3, true)] // default
+ [InlineData("contoso.redis.azure.net:10000,protocol=resp2", RedisProtocol.Resp2, false)] // opt-out
+ [InlineData("contoso.redis.azure.net:10000,protocol=resp3", RedisProtocol.Resp3, true)] // opt-in
+ // azure redis cache, no overrides (we expect this to change in v3)
+ [InlineData("contoso.redis.cache.windows.net:6380", null, false)] // default
+ [InlineData("contoso.redis.cache.windows.net:6380,protocol=resp2", RedisProtocol.Resp2, false)] // opt-out
+ [InlineData("contoso.redis.cache.windows.net:6380,protocol=resp3", RedisProtocol.Resp3, true)] // opt-in
+ // arbitrary endpoint (we expect this to change in v3)
+ [InlineData("myserver:6379", null, false)] // default
+ [InlineData("myserver:6379,protocol=resp2", RedisProtocol.Resp2, false)] // opt-out
+ [InlineData("myserver:6379,protocol=resp3", RedisProtocol.Resp3, true)] // opt-in
+ public void CorrectRespProtocol(string config, RedisProtocol? expected, bool useResp3)
+ {
+ var options = ConfigurationOptions.Parse(config);
+ Assert.Equal(expected, options.Protocol);
+ Assert.Equal(useResp3, options.TryResp3());
+ }
+
[Fact]
public void ConfigurationOptionsForAzureWhenSpecified()
{
diff --git a/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs b/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs
index a01e845da..8f6047e03 100644
--- a/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs
+++ b/tests/StackExchange.Redis.Tests/DefaultOptionsTests.cs
@@ -86,6 +86,93 @@ public void IsMatchOnAzureManagedRedisDomain(string hostName)
Assert.IsType(provider);
}
+ [Theory]
+ [InlineData(RedisProtocol.Resp2)]
+ [InlineData(RedisProtocol.Resp3)]
+ public async Task AzureManagedRedisConnectsWithoutSubscriptionConnection(RedisProtocol protocol)
+ {
+ using var serverObj = new InProcessTestServer(Output, new DnsEndPoint("contoso.redis.azure.net", 10000), useSsl: true);
+ var config = serverObj.GetClientConfig();
+ config.ClientName = Guid.NewGuid().ToString().Replace("-", "");
+ config.Protocol = protocol;
+
+ await using var conn = await ConnectionMultiplexer.ConnectAsync(config, Writer);
+
+ var server = conn.GetServer(conn.GetEndPoints().Single());
+ var interactiveId = ((IInternalConnectionMultiplexer)conn).GetConnectionId(server.EndPoint, ConnectionType.Interactive);
+ var clients = await server.ClientListAsync();
+ var namedClients = clients.Where(x => x.Name == config.ClientName).ToArray();
+
+ Assert.Equal(protocol, server.Protocol);
+ Assert.Equal(1, serverObj.ClientCount);
+ Assert.NotNull(interactiveId);
+ Assert.Single(namedClients);
+ var self = Assert.Single(clients, x => x.Id == interactiveId);
+ Assert.Equal(ClientType.Normal, self.ClientType);
+ Assert.Equal(0, self.SubscriptionCount);
+ Assert.Equal(0, self.PatternSubscriptionCount);
+ Assert.Equal(0, self.ShardedSubscriptionCount);
+ Assert.Equal(protocol, self.Protocol);
+
+ await AssertCanPubSubAsync(conn, $"{nameof(AzureManagedRedisConnectsWithoutSubscriptionConnection)}:{protocol}");
+ }
+
+ [Fact]
+ public async Task VanillaResp2ConnectsWithSeparatePubSubConnection()
+ {
+ using var serverObj = new InProcessTestServer(Output, new DnsEndPoint("redis.contoso.com", 10000), useSsl: true);
+ var config = serverObj.GetClientConfig();
+ config.Protocol = RedisProtocol.Resp2;
+ Log($"QueueWhileDisconnected: {config.BacklogPolicy.QueueWhileDisconnected}");
+
+ await using var conn = await ConnectionMultiplexer.ConnectAsync(config, Writer);
+ var sub = conn.GetSubscriber();
+ await sub.SubscribeAsync(RedisChannel.Literal(nameof(VanillaResp2ConnectsWithSeparatePubSubConnection)), (_, _) => { });
+
+ var server = conn.GetServer(conn.GetEndPoints().Single());
+ var mux = (IInternalConnectionMultiplexer)conn;
+ var interactiveId = mux.GetConnectionId(server.EndPoint, ConnectionType.Interactive);
+ var subscriptionId = mux.GetConnectionId(server.EndPoint, ConnectionType.Subscription);
+ var clients = server.ClientList();
+ var namedClients = clients.Where(x => x.Name == conn.ClientName).ToArray();
+
+ Assert.Equal(RedisProtocol.Resp2, server.Protocol);
+ Assert.Equal(2, serverObj.ClientCount);
+ Assert.NotNull(interactiveId);
+ Assert.NotNull(subscriptionId);
+ Assert.NotEqual(interactiveId, subscriptionId);
+ Assert.Equal(2, namedClients.Length);
+
+ var interactive = Assert.Single(clients, x => x.Id == interactiveId);
+ var subscription = Assert.Single(clients, x => x.Id == subscriptionId);
+ Assert.Equal(ClientType.Normal, interactive.ClientType);
+ Assert.Equal(ClientType.PubSub, subscription.ClientType);
+ Assert.True(subscription.SubscriptionCount > 0);
+
+ await AssertCanPubSubAsync(conn, nameof(VanillaResp2ConnectsWithSeparatePubSubConnection));
+ }
+
+ private static async Task AssertCanPubSubAsync(ConnectionMultiplexer conn, string channelName)
+ {
+ var sub = conn.GetSubscriber();
+ var channel = RedisChannel.Literal(channelName);
+ var payload = (RedisValue)("payload:" + channelName);
+ TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await sub.SubscribeAsync(channel, (_, message) => tcs.TrySetResult(message));
+ try
+ {
+ await sub.PublishAsync(channel, payload);
+ var completed = await Task.WhenAny(tcs.Task, Task.Delay(5000, TestContext.Current.CancellationToken));
+ Assert.Same(tcs.Task, completed);
+ Assert.Equal(payload, await tcs.Task);
+ }
+ finally
+ {
+ await sub.UnsubscribeAsync(channel);
+ }
+ }
+
[Fact]
public void AllOverridesFromDefaultsProp()
{
diff --git a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs
index af9f1ee44..e861bb6d3 100644
--- a/tests/StackExchange.Redis.Tests/InProcessTestServer.cs
+++ b/tests/StackExchange.Redis.Tests/InProcessTestServer.cs
@@ -3,7 +3,13 @@
using System.IO;
using System.IO.Pipelines;
using System.Net;
+using System.Net.Security;
using System.Net.Sockets;
+#if !NETFRAMEWORK
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+#endif
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -17,11 +23,28 @@ namespace StackExchange.Redis.Tests;
public class InProcessTestServer : MemoryCacheRedisServer
{
private readonly ITestOutputHelper? _log;
- public InProcessTestServer(ITestOutputHelper? log = null, EndPoint? endpoint = null)
+#if !NETFRAMEWORK
+ private readonly X509Certificate2? _serverCertificate;
+ private readonly string? _serverCertificateThumbprint;
+ private readonly RemoteCertificateValidationCallback? _certificateValidationCallback;
+#endif
+
+ public InProcessTestServer(ITestOutputHelper? log = null, EndPoint? endpoint = null, bool useSsl = false)
: base(endpoint)
{
RedisVersion = RedisFeatures.v6_0_0; // for client to expect RESP3
_log = log;
+#if NETFRAMEWORK
+ UseSsl = false;
+#else
+ UseSsl = useSsl;
+ if (useSsl)
+ {
+ _serverCertificate = CreateServerCertificate(DefaultEndPoint);
+ _serverCertificateThumbprint = _serverCertificate.Thumbprint;
+ _certificateValidationCallback = ValidateServerCertificate;
+ }
+#endif
// ReSharper disable once VirtualMemberCallInConstructor
_log?.WriteLine($"Creating in-process server: {ToString()}");
Tunnel = new InProcTunnel(this);
@@ -90,6 +113,13 @@ public ConfigurationOptions GetClientConfig(bool withPubSub = true, bool default
// WriteMode = (BufferedStreamWriter.WriteMode)writeMode,
};
if (!string.IsNullOrEmpty(Password)) config.Password = Password;
+ config.Ssl = UseSsl; // explicitly, ignore provider defaults
+ if (UseSsl)
+ {
+#if !NETFRAMEWORK
+ config.CertificateValidation += _certificateValidationCallback;
+#endif
+ }
/* useful for viewing *outbound* data in the log
#if DEBUG
@@ -121,6 +151,7 @@ public ConfigurationOptions GetClientConfig(bool withPubSub = true, bool default
}
public Tunnel Tunnel { get; }
+ public bool UseSsl { get; }
public override void Log(string message)
{
@@ -200,6 +231,70 @@ protected override void OnSkippedReply(RedisClient client)
base.OnSkippedReply(client);
}
+ protected override void Dispose(bool disposing)
+ {
+#if !NETFRAMEWORK
+ if (disposing)
+ {
+ _serverCertificate?.Dispose();
+ }
+#endif
+ base.Dispose(disposing);
+ }
+
+#if !NETFRAMEWORK
+ private bool ValidateServerCertificate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors errors)
+ {
+ if (errors == SslPolicyErrors.None)
+ {
+ return true;
+ }
+
+ return certificate is not null
+ && _serverCertificateThumbprint is not null
+ && string.Equals(certificate.GetCertHashString(), _serverCertificateThumbprint, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static X509Certificate2 CreateServerCertificate(EndPoint endpoint)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var subjectName = GetCertificateSubjectName(endpoint);
+
+ using var rsa = RSA.Create(2048);
+ var request = new CertificateRequest($"CN={subjectName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
+ request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false));
+ request.CertificateExtensions.Add(
+ new X509EnhancedKeyUsageExtension(
+ new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") },
+ false));
+
+ var san = new SubjectAlternativeNameBuilder();
+ switch (endpoint)
+ {
+ case DnsEndPoint dns:
+ san.AddDnsName(dns.Host);
+ break;
+ case IPEndPoint ip:
+ san.AddIpAddress(ip.Address);
+ break;
+ }
+ request.CertificateExtensions.Add(san.Build());
+
+ using var certificate = request.CreateSelfSigned(now.AddMinutes(-5), now.AddDays(7));
+#pragma warning disable SYSLIB0057
+ return new X509Certificate2(certificate.Export(X509ContentType.Pfx));
+#pragma warning restore SYSLIB0057
+
+ static string GetCertificateSubjectName(EndPoint endpoint) => endpoint switch
+ {
+ DnsEndPoint dns => dns.Host,
+ IPEndPoint ip => ip.Address.ToString(),
+ _ => "localhost",
+ };
+ }
+#endif
+
private sealed class InProcTunnel(
InProcessTestServer server,
PipeOptions? pipeOptions = null) : Tunnel
@@ -225,16 +320,40 @@ private sealed class InProcTunnel(
if (server.TryGetNode(endpoint, out var node))
{
await server.OnAcceptClientAsync(endpoint);
+ server._log?.WriteLine(
+ $"[{endpoint}] accepting {connectionType} mapped to {server.ServerType} node {node} via {(server.UseSsl ? "TLS" : "plaintext")}");
var clientToServer = new Pipe(pipeOptions ?? PipeOptions.Default);
var serverToClient = new Pipe(pipeOptions ?? PipeOptions.Default);
- var serverSide = new Duplex(clientToServer.Reader, serverToClient.Writer);
+ var serverInput = clientToServer.Reader.AsStream();
+ var serverOutput = serverToClient.Writer.AsStream();
+ var serverTransport = new DuplexStream(serverInput, serverOutput);
- TaskCompletionSource clientTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
- Task.Run(async () => await server.RunClientAsync(serverSide, node: node, state: clientTcs), cancellationToken).RedisFireAndForget();
- if (!clientTcs.Task.Wait(1000)) throw new TimeoutException("Client not connected");
- var client = clientTcs.Task.Result;
- server._log?.WriteLine(
- $"[{client}] connected ({connectionType} mapped to {server.ServerType} node {node})");
+ if (server.UseSsl)
+ {
+#if !NETFRAMEWORK
+ Task.Run(
+ async () =>
+ {
+ using var ssl = new SslStream(serverTransport, leaveInnerStreamOpen: false);
+ await ssl.AuthenticateAsServerAsync(
+ server._serverCertificate!,
+ clientCertificateRequired: false,
+ enabledSslProtocols: SslProtocols.None,
+ checkCertificateRevocation: false).ConfigureAwait(false);
+ var serverSide = new StreamDuplexPipe(ssl);
+ await server.RunClientAsync(serverSide, node: node, state: null).ConfigureAwait(false);
+ },
+ cancellationToken).RedisFireAndForget();
+#endif
+ }
+ else
+ {
+ var serverSide = new Duplex(clientToServer.Reader, serverToClient.Writer);
+ TaskCompletionSource clientTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ Task.Run(async () => await server.RunClientAsync(serverSide, node: node, state: clientTcs), cancellationToken).RedisFireAndForget();
+ if (!clientTcs.Task.Wait(1000)) throw new TimeoutException("Client not connected");
+ _ = clientTcs.Task.Result;
+ }
var readStream = serverToClient.Reader.AsStream();
var writeStream = clientToServer.Writer.AsStream();
@@ -256,6 +375,12 @@ public ValueTask Dispose()
return default;
}
}
+
+ private sealed class StreamDuplexPipe(Stream stream) : IDuplexPipe
+ {
+ public PipeReader Input { get; } = PipeReader.Create(stream);
+ public PipeWriter Output { get; } = PipeWriter.Create(stream);
+ }
}
protected virtual ValueTask OnAcceptClientAsync(EndPoint endpoint) => default;
diff --git a/tests/StackExchange.Redis.Tests/SSLTests.cs b/tests/StackExchange.Redis.Tests/SSLTests.cs
index 96d964b23..f068e0aae 100644
--- a/tests/StackExchange.Redis.Tests/SSLTests.cs
+++ b/tests/StackExchange.Redis.Tests/SSLTests.cs
@@ -416,6 +416,8 @@ public void Issue883_Exhaustive()
Ssl = true,
AbortOnConnectFail = false,
};
+ _ = a.Defaults;
+ _ = b.Defaults; // ensure the lazily materialized provider matches the parsed shape
Log($"computed: {b.ToString(true)}");
Log("Checking endpoints...");
@@ -429,6 +431,14 @@ public void Issue883_Exhaustive()
Array.Sort(fields, (x, y) => string.CompareOrdinal(x.Name, y.Name));
foreach (var field in fields)
{
+ if (field.Name == "defaultOptions")
+ {
+ var x = field.GetValue(a);
+ var y = field.GetValue(b);
+ Log($"{field.Name}: {(x == null ? "(null)" : x.GetType().Name)} vs {(y == null ? "(null)" : y.GetType().Name)}");
+ Check(field.Name + ".Type", x?.GetType(), y?.GetType());
+ continue;
+ }
Check(field.Name, field.GetValue(a), field.GetValue(b));
}
}
diff --git a/toys/StackExchange.Redis.Server/RedisServer.cs b/toys/StackExchange.Redis.Server/RedisServer.cs
index 54a7fbe04..a3b52dec1 100644
--- a/toys/StackExchange.Redis.Server/RedisServer.cs
+++ b/toys/StackExchange.Redis.Server/RedisServer.cs
@@ -462,6 +462,32 @@ protected virtual TypedRedisValue ClientReply(RedisClient client, in RedisReques
protected virtual TypedRedisValue ClientId(RedisClient client, in RedisRequest request)
=> TypedRedisValue.Integer(client.Id);
+ [RedisCommand(2, nameof(RedisCommand.CLIENT), "list", LockFree = true)]
+ protected virtual TypedRedisValue ClientList(RedisClient client, in RedisRequest request)
+ {
+ var sb = new StringBuilder();
+ ForAllClients(
+ sb,
+ static (other, state) =>
+ {
+ if (state.Length != 0) state.AppendLine();
+ state.Append("id=").Append(other.Id)
+ .Append(" addr=").Append(other.Node.Host).Append(':').Append(other.Node.Port)
+ .Append(" age=0 idle=0")
+ .Append(" db=").Append(other.Database)
+ .Append(" sub=").Append(other.SubscriptionCount)
+ .Append(" psub=").Append(other.PatternSubscriptionCount)
+ .Append(" ssub=").Append(other.ShardedSubscriptionCount)
+ .Append(" multi=0")
+ .Append(" cmd=NULL")
+ .Append(" name=").Append(other.Name ?? "")
+ .Append(" resp=").Append(other.Protocol is RedisProtocol.Resp3 ? 3 : 2)
+ .Append(" flags=").Append(other.IsSubscriber ? "P" : "N");
+ return 1;
+ });
+ return TypedRedisValue.BulkString(sb.ToString());
+ }
+
[RedisCommand(4, nameof(RedisCommand.CLIENT), "setinfo", LockFree = true)]
protected virtual TypedRedisValue ClientSetInfo(RedisClient client, in RedisRequest request)
=> TypedRedisValue.OK; // only exists to keep logs clean
diff --git a/version.json b/version.json
index c2ded472b..9500cbb6f 100644
--- a/version.json
+++ b/version.json
@@ -1,5 +1,5 @@
{
- "version": "2.12",
+ "version": "2.13",
"versionHeightOffset": 0,
"assemblyVersion": "2.0",
"publicReleaseRefSpec": [