From 110fc63db8c00ac102180b6912c65772dc3b145e Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Sun, 24 May 2026 11:42:37 +0100 Subject: [PATCH 01/10] investigation into socket failure --- Directory.Build.props | 2 +- Directory.Packages.props | 1 + .../StackExchange.Redis.Tests.csproj | 4 +- .../TcpKeepAliveTests.cs | 126 ++++++++++++++++++ 4 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index a136f3c66..4e62d5e0f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -38,7 +38,7 @@ - + diff --git a/Directory.Packages.props b/Directory.Packages.props index 4f65bb324..71230ccbf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -39,5 +39,6 @@ + \ No newline at end of file diff --git a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj index 4c312d448..9a4394656 100644 --- a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj +++ b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj @@ -1,7 +1,7 @@  - net481;net10.0 + net481;net8.0;net10.0 Exe StackExchange.Redis.Tests true @@ -33,5 +33,7 @@ + + diff --git a/tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs b/tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs new file mode 100644 index 000000000..af64acacd --- /dev/null +++ b/tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Buffers.Binary; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class TcpKeepAliveTests(ITestOutputHelper log, TcpTestFixture fixture) : IClassFixture +{ + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task Roundtrip(bool ip, bool keepAlive) + { + using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + EndPoint ep = ip ? fixture.IP : fixture.Dns; + log.WriteLine($"Connecting to {Format.ToString(ep)}, keepAlive: {keepAlive}"); + if (keepAlive) + { + client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); + } + await client.ConnectAsync(ep); + + byte[] buffer = new byte[4]; + int i = random.Next(int.MinValue, int.MaxValue); + BinaryPrimitives.WriteInt32LittleEndian(buffer, i); + client.Send(buffer, 0, 4, SocketFlags.None); + Array.Clear(buffer, 0, buffer.Length); + Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(buffer)); + int bytesRead, count = 0; + while (count < buffer.Length && + (bytesRead = client.Receive(buffer, count, buffer.Length - count, SocketFlags.None)) > 0) + { + count += bytesRead; + } + if (count != buffer.Length) throw new EndOfStreamException(); + Assert.Equal(i, BinaryPrimitives.ReadInt32LittleEndian(buffer)); + } + private static readonly Random random = new(); +} + +public class TcpTestFixture : IDisposable +{ + private TcpListener server; + private CancellationTokenSource cts = new(); + public IPEndPoint IP { get; } + public DnsEndPoint Dns { get; } + + public TcpTestFixture() + { + int port = 18000; +#if NET10_OR_GREATER + port += 1; +#elif NET8_0_OR_GREATER + port += 2; +#elif NET6_0_OR_GREATER + port += 3; +#endif + var ip = IPAddress.Parse("172.24.32.1"); + IP = new(ip, port); + Dns = new("marc-z13", port); + server = new TcpListener(ip, port); + server.Start(); + _ = Task.Run(async () => + { + try + { + while (!cts.IsCancellationRequested) + { + #if NET + var client = await server.AcceptTcpClientAsync(cts.Token); + #else + var client = await server.AcceptTcpClientAsync(); + #endif + _ = Task.Run(() => RunClient(client, cts.Token)); + } + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + } + }); + } + + private static async Task? RunClient(TcpClient client, CancellationToken cancel) + { + // echo up to 4 bytes + try + { + using var stream = client.GetStream(); + byte[] buffer = new byte[4]; + int bytesRead, count = 0; + while (count < buffer.Length && + (bytesRead = await stream.ReadAsync(buffer, count, buffer.Length - count, cancel)) > 0) + { + await stream.WriteAsync(buffer, count, bytesRead, cancel); + count += bytesRead; + } + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + } + finally + { + client.Dispose(); + } + } + + public void Dispose() + { + cts.Cancel(); + server.Stop(); + #if NET + server.Dispose(); + #endif + } +} From eedcf845ed64f5b77bd4fdcb7d7b44ea99b8725d Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 26 May 2026 09:14:15 +0100 Subject: [PATCH 02/10] 1. make TCP keep-alives an explicit option (default: "on") 2. hard-disable TCP keep-alives on DNS endpoints for non-Windows before .NET 10 --- .../Configuration/DefaultOptionsProvider.cs | 5 ++ .../ConfigurationOptions.cs | 18 ++++++- src/StackExchange.Redis/PhysicalConnection.cs | 5 +- .../PublicAPI/PublicAPI.Unshipped.txt | 3 ++ src/StackExchange.Redis/SocketManager.cs | 50 ++++++++++++++----- .../StackExchange.Redis.Tests/ConfigTests.cs | 1 + .../TcpKeepAliveTests.cs | 17 +++++-- 7 files changed, 79 insertions(+), 20 deletions(-) diff --git a/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs b/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs index f560c8ce4..d911db0e3 100644 --- a/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs +++ b/src/StackExchange.Redis/Configuration/DefaultOptionsProvider.cs @@ -264,6 +264,11 @@ protected virtual string GetDefaultClientName() => /// public virtual RedisProtocol? Protocol => null; + /// + /// Gets whether to enable TCP keep-alive when appropriate (endpoint- and platform-dependent). + /// + public virtual bool TcpKeepAlive => true; + /// /// 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 59bee48ca..41be90abc 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -111,7 +111,8 @@ internal const string Tunnel = "tunnel", SetClientLibrary = "setlib", Protocol = "protocol", - HighIntegrity = "highIntegrity"; + HighIntegrity = "highIntegrity", + TcpKeepAlive = "tcpkeepalive"; private static readonly Dictionary normalizedOptions = new[] { @@ -169,6 +170,7 @@ public static string TryNormalize(string value) private Version? defaultVersion; private int? keepAlive, asyncTimeout, syncTimeout, connectTimeout, responseTimeout, connectRetry, configCheckSeconds; + private bool? tcpKeepAlive; private Proxy? proxy; @@ -596,6 +598,15 @@ public int KeepAlive set => keepAlive = value; } + /// + /// Gets or sets whether to enable TCP keep-alive when appropriate (endpoint- and platform-dependent). + /// + public bool TcpKeepAlive + { + get => tcpKeepAlive ?? Defaults.TcpKeepAlive; + set => tcpKeepAlive = value; + } + /// /// The to get loggers for connection events. /// Note: changes here only affect s created after. @@ -847,6 +858,7 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow heartbeatInterval = heartbeatInterval, heartbeatConsistencyChecks = heartbeatConsistencyChecks, highIntegrity = highIntegrity, + tcpKeepAlive = tcpKeepAlive, }; /// @@ -929,6 +941,7 @@ public string ToString(bool includePassword) Append(sb, OptionKeys.SetClientLibrary, setClientLibrary); Append(sb, OptionKeys.HighIntegrity, highIntegrity); Append(sb, OptionKeys.Protocol, FormatProtocol(_protocol)); + Append(sb, OptionKeys.TcpKeepAlive, tcpKeepAlive); if (Tunnel is { IsInbuilt: true } tunnel) { Append(sb, OptionKeys.Tunnel, tunnel.ToString()); @@ -1095,6 +1108,9 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown) case OptionKeys.HighIntegrity: HighIntegrity = OptionKeys.ParseBoolean(key, value); break; + case OptionKeys.TcpKeepAlive: + TcpKeepAlive = OptionKeys.ParseBoolean(key, value); + break; case OptionKeys.Tunnel: if (value.IsNullOrWhiteSpace()) { diff --git a/src/StackExchange.Redis/PhysicalConnection.cs b/src/StackExchange.Redis/PhysicalConnection.cs index f72a63ae5..ab075a304 100644 --- a/src/StackExchange.Redis/PhysicalConnection.cs +++ b/src/StackExchange.Redis/PhysicalConnection.cs @@ -113,7 +113,8 @@ internal async Task BeginConnectAsync(ILogger? log) } Trace("Connecting..."); - var tunnel = bridge.Multiplexer.RawConfig.Tunnel; + var rawConfig = bridge.Multiplexer.RawConfig; + var tunnel = rawConfig.Tunnel; var connectTo = endpoint; if (tunnel is not null) { @@ -121,7 +122,7 @@ internal async Task BeginConnectAsync(ILogger? log) } if (connectTo is not null) { - _socket = SocketManager.CreateSocket(connectTo); + _socket = SocketManager.CreateSocket(connectTo, rawConfig.TcpKeepAlive); } if (_socket is not null) diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt index 2af96c4be..cee4b2762 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt @@ -1,4 +1,7 @@ #nullable enable +StackExchange.Redis.ConfigurationOptions.TcpKeepAlive.get -> bool +StackExchange.Redis.ConfigurationOptions.TcpKeepAlive.set -> void +virtual StackExchange.Redis.Configuration.DefaultOptionsProvider.TcpKeepAlive.get -> bool [SER006]StackExchange.Redis.ArrayGrepRequest.IsCaseInsensitive.get -> bool [SER006]StackExchange.Redis.ArrayGrepRequest.IsCaseInsensitive.set -> void [SER006]StackExchange.Redis.ArrayGrepRequest.IsReversed.get -> bool diff --git a/src/StackExchange.Redis/SocketManager.cs b/src/StackExchange.Redis/SocketManager.cs index 7c521c93e..d75d473d9 100644 --- a/src/StackExchange.Redis/SocketManager.cs +++ b/src/StackExchange.Redis/SocketManager.cs @@ -3,6 +3,7 @@ using System.IO.Pipelines; using System.Net; using System.Net.Sockets; +using System.Runtime.InteropServices; using System.Threading; using Pipelines.Sockets.Unofficial; @@ -215,7 +216,7 @@ private void DisposeRefs() /// ~SocketManager() => DisposeRefs(); - internal static Socket CreateSocket(EndPoint endpoint) + internal static Socket CreateSocket(EndPoint endpoint, bool tcpKeepAlive) { var addressFamily = endpoint.AddressFamily; var protocolType = addressFamily == AddressFamily.Unix ? ProtocolType.Unspecified : ProtocolType.Tcp; @@ -224,18 +225,7 @@ internal static Socket CreateSocket(EndPoint endpoint) ? new Socket(SocketType.Stream, protocolType) : new Socket(addressFamily, SocketType.Stream, protocolType); SocketConnection.SetRecommendedClientOptions(socket); - if (protocolType is ProtocolType.Tcp) - { - try - { - // enable TCP keep-alive (best effort only) - socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); - } - catch (Exception ex) - { - Debug.WriteLine(ex.Message); - } - } + if (tcpKeepAlive) TryEnableTcpKeepAlive(socket, endpoint); return socket; } @@ -246,5 +236,39 @@ internal static Socket CreateSocket(EndPoint endpoint) var s = SchedulerPool; return s == null ? null : $"{s.AvailableCount} of {s.WorkerCount} available"; } + + internal static bool TryEnableTcpKeepAlive(Socket socket, EndPoint endPoint) + { + // TCP keep-alive; there's a clue in the name + if (socket.ProtocolType is not ProtocolType.Tcp) return false; + + switch (endPoint) + { +#if !NET10_0_OR_GREATER + // Prior to .NET 10, enabling TCP keep-alive on host-based endpoints fails outside of Windows. + // see https://github.com/StackExchange/StackExchange.Redis/issues/3086 + case DnsEndPoint when !RuntimeInformation.IsOSPlatform(OSPlatform.Windows): return false; +#endif + case DnsEndPoint: + case IPEndPoint: + // fine + break; + default: + // don't enable on unexpected endpoint types (unix domain sockets, for example) + return false; + } + + try + { + // enable TCP keep-alive (best effort only) + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); + return true; + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + return false; + } + } } } diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index 9a4bca864..37805224e 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -79,6 +79,7 @@ orderby name "sslHost", "SslProtocols", "syncTimeout", + "tcpKeepAlive", "tieBreaker", "Tunnel", "user", diff --git a/tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs b/tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs index af64acacd..bc981e928 100644 --- a/tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs +++ b/tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net; using System.Net.Sockets; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -20,13 +21,19 @@ public class TcpKeepAliveTests(ITestOutputHelper log, TcpTestFixture fixture) : [InlineData(false, false)] public async Task Roundtrip(bool ip, bool keepAlive) { + #if NETFRAMEWORK + Assert.SkipWhen( + !ip && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Mono has glitches with DNS endpoints"); + #endif using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); EndPoint ep = ip ? fixture.IP : fixture.Dns; - log.WriteLine($"Connecting to {Format.ToString(ep)}, keepAlive: {keepAlive}"); + log.WriteLine($"Connecting to {Format.ToString(ep)}, {ep.AddressFamily}, keepAlive: {keepAlive}"); if (keepAlive) { - client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); + Assert.SkipUnless(SocketManager.TryEnableTcpKeepAlive(client, ep), "keep-alive not supported"); } + await client.ConnectAsync(ep); byte[] buffer = new byte[4]; @@ -64,9 +71,11 @@ public TcpTestFixture() #elif NET6_0_OR_GREATER port += 3; #endif - var ip = IPAddress.Parse("172.24.32.1"); + var host = Environment.MachineName; + var ip = System.Net.Dns.GetHostEntry(host).AddressList.First(x => x.AddressFamily is AddressFamily.InterNetwork); + IP = new(ip, port); - Dns = new("marc-z13", port); + Dns = new(host, port, AddressFamily.InterNetwork); server = new TcpListener(ip, port); server.Start(); _ = Task.Run(async () => From ab6ad7aa4ee101ebe365b632444ac36f77d28ed2 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 26 May 2026 09:18:50 +0100 Subject: [PATCH 03/10] release notes --- docs/ReleaseNotes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md index aae40a9c6..56bde9288 100644 --- a/docs/ReleaseNotes.md +++ b/docs/ReleaseNotes.md @@ -9,6 +9,7 @@ Current package versions: ## Unreleased - Fix logic inversion with `ARGREP NOCASE`, add `IsReversed` to simplify ordering, and support `ARINFO FULL`. ([#3087 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3087)) +- Fix TCP platform-dependent TCP keep-alive problems, and make an explicit option. ([#3090 by @mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/3090)) ## 2.13.10 From 968002582a9b9b23e7047ba60efd31873f78b8cb Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 26 May 2026 09:25:37 +0100 Subject: [PATCH 04/10] update CI image to 8.8 --- tests/RedisConfigs/.docker/Redis/Dockerfile | 2 +- tests/RedisConfigs/docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/RedisConfigs/.docker/Redis/Dockerfile b/tests/RedisConfigs/.docker/Redis/Dockerfile index 76d4030ee..f5e3f196a 100644 --- a/tests/RedisConfigs/.docker/Redis/Dockerfile +++ b/tests/RedisConfigs/.docker/Redis/Dockerfile @@ -1,4 +1,4 @@ -ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:8.8-rc1 +ARG CLIENT_LIBS_TEST_IMAGE=redislabs/client-libs-test:8.8.0 FROM ${CLIENT_LIBS_TEST_IMAGE} COPY --from=configs ./Basic /data/Basic/ diff --git a/tests/RedisConfigs/docker-compose.yml b/tests/RedisConfigs/docker-compose.yml index e5b77344d..ce5475aaf 100644 --- a/tests/RedisConfigs/docker-compose.yml +++ b/tests/RedisConfigs/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: .docker/Redis args: - CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.8-rc1} + CLIENT_LIBS_TEST_IMAGE: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.8.0} additional_contexts: configs: . platform: linux From 2e1ae564df1279968dbb5c6b5342d8af20726497 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 27 May 2026 08:57:23 +0100 Subject: [PATCH 05/10] fix typo in runtime test isolation / port selection. --- tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs b/tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs index bc981e928..5e226546e 100644 --- a/tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs +++ b/tests/StackExchange.Redis.Tests/TcpKeepAliveTests.cs @@ -64,7 +64,7 @@ public class TcpTestFixture : IDisposable public TcpTestFixture() { int port = 18000; -#if NET10_OR_GREATER +#if NET10_0_OR_GREATER port += 1; #elif NET8_0_OR_GREATER port += 2; From 957c888b419b5e4929010ead973e91f5c0ed67ac Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 27 May 2026 09:14:42 +0100 Subject: [PATCH 06/10] Add OptionKeysAreAllNormalized to ensure all keys are correctly normalized; add tcpKeepAlive to docs --- docs/Configuration.md | 1 + .../ConfigurationOptions.cs | 6 +++++- .../StackExchange.Redis.Tests/ConfigTests.cs | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/Configuration.md b/docs/Configuration.md index 96e4b5bae..43e48cfc7 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -83,6 +83,7 @@ The `ConfigurationOptions` object has a wide range of properties, all of which a | configCheckSeconds={int} | `ConfigCheckSeconds` | `60` | Time (seconds) to check configuration. This serves as a keep-alive for interactive sockets, if it is supported. | | defaultDatabase={int} | `DefaultDatabase` | `null` | Default database index, from `0` to `databases - 1` | | keepAlive={int} | `KeepAlive` | `-1` | Time (seconds) at which to send a message to help keep sockets alive (60 sec default) | +| tcpKeepAlive={bool} | `TcpKeepAlive` | `true` | Enables TCP keep-alive when appropriate (endpoint- and platform-dependent) | | name={string} | `ClientName` | `null` | Identification for the connection within redis | | password={string} | `Password` | `null` | Password for the redis server | | user={string} | `User` | `null` | User for the redis server (for use with ACLs on redis 6 and above) | diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs index 41be90abc..a0a10791c 100644 --- a/src/StackExchange.Redis/ConfigurationOptions.cs +++ b/src/StackExchange.Redis/ConfigurationOptions.cs @@ -112,7 +112,7 @@ internal const string SetClientLibrary = "setlib", Protocol = "protocol", HighIntegrity = "highIntegrity", - TcpKeepAlive = "tcpkeepalive"; + TcpKeepAlive = "tcpKeepAlive"; private static readonly Dictionary normalizedOptions = new[] { @@ -133,6 +133,7 @@ internal const string PreserveAsyncOrder, Proxy, ResolveDns, + ResponseTimeout, ServiceName, Ssl, SslHost, @@ -142,8 +143,11 @@ internal const string Version, WriteBuffer, CheckCertificateRevocation, + Tunnel, + SetClientLibrary, Protocol, HighIntegrity, + TcpKeepAlive, }.ToDictionary(x => x, StringComparer.OrdinalIgnoreCase); public static string TryNormalize(string value) diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs index 37805224e..a2527b985 100644 --- a/tests/StackExchange.Redis.Tests/ConfigTests.cs +++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs @@ -87,6 +87,25 @@ orderby name fields); } + [Fact] + public void OptionKeysAreAllNormalized() + { + var optionKeys = typeof(ConfigurationOptions).GetNestedType("OptionKeys", BindingFlags.NonPublic)!; + var constants = ( + from field in optionKeys.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static) + where field.IsLiteral && !field.IsInitOnly && field.FieldType == typeof(string) + orderby field.Name + select (string)field.GetRawConstantValue()!).ToArray(); + + var normalizedOptions = (System.Collections.Generic.IReadOnlyDictionary)optionKeys + .GetField("normalizedOptions", BindingFlags.NonPublic | BindingFlags.Static)! + .GetValue(null)!; + + Assert.Equal( + constants.OrderBy(x => x, StringComparer.Ordinal), + normalizedOptions.Keys.OrderBy(x => x, StringComparer.Ordinal)); + } + [Fact] public void SslProtocols_SingleValue() { From f6fee4653f04f3366dc47899fa0c448d91b8fa48 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 27 May 2026 09:31:08 +0100 Subject: [PATCH 07/10] stabilize DeleteLastElementPublishesArrayDeleteBeforeKeyDeleteNotifications --- tests/StackExchange.Redis.Tests/ArrayTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/StackExchange.Redis.Tests/ArrayTests.cs b/tests/StackExchange.Redis.Tests/ArrayTests.cs index 8ef29b987..1bf2debd2 100644 --- a/tests/StackExchange.Redis.Tests/ArrayTests.cs +++ b/tests/StackExchange.Redis.Tests/ArrayTests.cs @@ -136,6 +136,8 @@ public async Task DeleteLastElementPublishesArrayDeleteBeforeKeyDeleteNotificati var sub = conn.GetSubscriber(); var channel = RedisChannel.Pattern($"__key*@{db.Database}__:*"); var queue = await sub.SubscribeAsync(channel); + await Task.Delay(100); + await sub.PingAsync(); try { Assert.True(await db.ArraySetAsync(key, 0, "a")); From efbe95e4f7be40b1199dfe05d264f44181e53609 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 27 May 2026 10:06:18 +0100 Subject: [PATCH 08/10] allow more grace while waiting for ArrayTests work; only run that test in DEBUG --- tests/StackExchange.Redis.Tests/ArrayTests.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/ArrayTests.cs b/tests/StackExchange.Redis.Tests/ArrayTests.cs index 1bf2debd2..fe441bf41 100644 --- a/tests/StackExchange.Redis.Tests/ArrayTests.cs +++ b/tests/StackExchange.Redis.Tests/ArrayTests.cs @@ -126,6 +126,9 @@ public async Task DeleteAndDeleteRange() [Fact(Timeout = 10000)] public async Task DeleteLastElementPublishesArrayDeleteBeforeKeyDeleteNotifications() { + #if !DEBUG + Assert.Skip("Debug only due to parallelism overhead"); + #endif await using var conn = Create(allowAdmin: true, require: RedisFeatures.v8_8_0); var db = conn.GetDatabase(); await AssertArrayKeyspaceNotificationsEnabledAsync(conn); @@ -631,9 +634,12 @@ private static void AssertValues(RedisValue[] actual, params RedisValue[] expect private async Task<(KeyNotificationKind Kind, KeyNotificationType Type)> ReadNotificationAsync(ChannelMessageQueue queue, RedisKey key) { - for (int i = 0; i < 64; i++) + // there might be a lot of parallel notifications happening from parallel tests; as such, we might need to skip a lot of unrelated + // stuff - allow for the timeout + var ct = TestContext.Current.CancellationToken; + while (!ct.IsCancellationRequested) { - var message = await queue.ReadAsync(TestContext.Current.CancellationToken); + var message = await queue.ReadAsync(ct); if (message.TryParseKeyNotification(out var notification)) { Log($"{notification.Kind}, {notification.Type} {message}"); From 1ee89c342b2541c81ac7ae0efebdf883f8e8e663 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 27 May 2026 10:39:36 +0100 Subject: [PATCH 09/10] reduce CI brittleness by restricting tests that have wide side-effects; they can't run in parallel between runtimes --- .../StackExchange.Redis.Tests/ClusterTests.cs | 4 +++ .../DatabaseTests.cs | 2 ++ .../InProcessDatabaseUnitTests.cs | 6 ++-- .../Issues/MassiveDeleteTests.cs | 4 +++ tests/StackExchange.Redis.Tests/KeyTests.cs | 4 +++ .../MultiPrimaryTests.cs | 2 ++ tests/StackExchange.Redis.Tests/ScanTests.cs | 6 ++++ .../ScriptingTests.cs | 28 +++++++++++++++++-- .../StackExchange.Redis.Tests.csproj | 3 ++ tests/StackExchange.Redis.Tests/TestBase.cs | 10 +++++++ 10 files changed, 64 insertions(+), 5 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/ClusterTests.cs b/tests/StackExchange.Redis.Tests/ClusterTests.cs index 80ff8a830..e44ee8be4 100644 --- a/tests/StackExchange.Redis.Tests/ClusterTests.cs +++ b/tests/StackExchange.Redis.Tests/ClusterTests.cs @@ -521,6 +521,8 @@ public async Task TransactionWithSameSlotKeys() [InlineData("abc", 100)] public async Task Keys(string? pattern, int pageSize) { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true); var dbId = TestConfig.GetDedicatedDB(conn); @@ -621,6 +623,8 @@ public async Task GetConfig() [Fact(Skip = "FlushAllDatabases")] public async Task AccessRandomKeys() { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true); var cluster = conn.GetDatabase(); diff --git a/tests/StackExchange.Redis.Tests/DatabaseTests.cs b/tests/StackExchange.Redis.Tests/DatabaseTests.cs index bb134c4fd..680f67837 100644 --- a/tests/StackExchange.Redis.Tests/DatabaseTests.cs +++ b/tests/StackExchange.Redis.Tests/DatabaseTests.cs @@ -67,6 +67,8 @@ public async Task CommandList() [Fact] public async Task CountKeys() { + NoConcurrentRuntime(); + var db1Id = TestConfig.GetDedicatedDB(); var db2Id = TestConfig.GetDedicatedDB(); await using (var conn = Create(allowAdmin: true)) diff --git a/tests/StackExchange.Redis.Tests/InProcessDatabaseUnitTests.cs b/tests/StackExchange.Redis.Tests/InProcessDatabaseUnitTests.cs index 941e1eb15..1e05e971f 100644 --- a/tests/StackExchange.Redis.Tests/InProcessDatabaseUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/InProcessDatabaseUnitTests.cs @@ -5,12 +5,14 @@ namespace StackExchange.Redis.Tests; [RunPerProtocol] -public class InProcessDatabaseUnitTests(ITestOutputHelper output) +public class InProcessDatabaseUnitTests(ITestOutputHelper output) : TestBase(output) { [Fact] public async Task DatabasesAreIsolatedAndCanBeFlushed() { - using var server = new InProcessTestServer(output); + NoConcurrentRuntime(); + + using var server = new InProcessTestServer(Output); await using var conn = await server.ConnectAsync(); var admin = conn.GetServer(conn.GetEndPoints()[0]); diff --git a/tests/StackExchange.Redis.Tests/Issues/MassiveDeleteTests.cs b/tests/StackExchange.Redis.Tests/Issues/MassiveDeleteTests.cs index 94590a186..270ef47ce 100644 --- a/tests/StackExchange.Redis.Tests/Issues/MassiveDeleteTests.cs +++ b/tests/StackExchange.Redis.Tests/Issues/MassiveDeleteTests.cs @@ -10,6 +10,8 @@ public class MassiveDeleteTests(ITestOutputHelper output) : TestBase(output) { private async Task Prep(int dbId, string key) { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true); var prefix = Me(); @@ -29,6 +31,8 @@ private async Task Prep(int dbId, string key) [Fact] public async Task ExecuteMassiveDelete() { + NoConcurrentRuntime(); + Skip.UnlessLongRunning(); var dbId = TestConfig.GetDedicatedDB(); var key = Me(); diff --git a/tests/StackExchange.Redis.Tests/KeyTests.cs b/tests/StackExchange.Redis.Tests/KeyTests.cs index e956af4ff..37479a069 100644 --- a/tests/StackExchange.Redis.Tests/KeyTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyTests.cs @@ -14,6 +14,8 @@ public class KeyTests(ITestOutputHelper output, SharedConnectionFixture fixture) [Fact] public async Task TestScan() { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true); var dbId = TestConfig.GetDedicatedDB(conn); @@ -33,6 +35,8 @@ public async Task TestScan() [Fact] public async Task FlushFetchRandomKey() { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true); var dbId = TestConfig.GetDedicatedDB(conn); diff --git a/tests/StackExchange.Redis.Tests/MultiPrimaryTests.cs b/tests/StackExchange.Redis.Tests/MultiPrimaryTests.cs index 3d88e097c..08dd35947 100644 --- a/tests/StackExchange.Redis.Tests/MultiPrimaryTests.cs +++ b/tests/StackExchange.Redis.Tests/MultiPrimaryTests.cs @@ -14,6 +14,8 @@ protected override string GetConfiguration() => [Fact] public async Task CannotFlushReplica() { + NoConcurrentRuntime(); + var ex = await Assert.ThrowsAsync(async () => { await using var conn = await ConnectionMultiplexer.ConnectAsync(TestConfig.Current.ReplicaServerAndPort + ",allowAdmin=true"); diff --git a/tests/StackExchange.Redis.Tests/ScanTests.cs b/tests/StackExchange.Redis.Tests/ScanTests.cs index fe03cbf86..99b8e544d 100644 --- a/tests/StackExchange.Redis.Tests/ScanTests.cs +++ b/tests/StackExchange.Redis.Tests/ScanTests.cs @@ -14,6 +14,8 @@ public class ScanTests(ITestOutputHelper output, SharedConnectionFixture fixture [InlineData(false)] public async Task KeysScan(bool supported) { + NoConcurrentRuntime(); + string[]? disabledCommands = supported ? null : ["scan"]; await using var conn = Create(disabledCommands: disabledCommands, allowAdmin: true); @@ -51,6 +53,8 @@ public async Task KeysScan(bool supported) [Fact] public async Task ScansIScanning() { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true); var prefix = Me() + Guid.NewGuid(); @@ -98,6 +102,8 @@ public async Task ScansIScanning() [Fact] public async Task ScanResume() { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true, require: RedisFeatures.v2_8_0); var dbId = TestConfig.GetDedicatedDB(conn); diff --git a/tests/StackExchange.Redis.Tests/ScriptingTests.cs b/tests/StackExchange.Redis.Tests/ScriptingTests.cs index 15ea6adb1..6416e32ad 100644 --- a/tests/StackExchange.Redis.Tests/ScriptingTests.cs +++ b/tests/StackExchange.Redis.Tests/ScriptingTests.cs @@ -184,6 +184,8 @@ public async Task DisableStringInference() [Fact] public async Task FlushDetection() { + NoConcurrentRuntime(); + // we don't expect this to handle everything; we just expect it to be predictable await using var conn = GetScriptConn(allowAdmin: true); @@ -206,6 +208,8 @@ public async Task FlushDetection() [Fact] public async Task PrepareScript() { + NoConcurrentRuntime(); + string[] scripts = ["return redis.call('get', KEYS[1])", "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}"]; await using (var conn = GetScriptConn(allowAdmin: true)) { @@ -389,6 +393,8 @@ public async Task TestBasicScripting() [InlineData(false)] public async Task CheckLoads(bool async) { + NoConcurrentRuntime(); + await using var conn0 = Create(allowAdmin: true, require: RedisFeatures.v2_6_0); await using var conn1 = Create(allowAdmin: true, require: RedisFeatures.v2_6_0); @@ -437,6 +443,8 @@ public async Task CheckLoads(bool async) [Fact] public async Task CompareScriptToDirect() { + NoConcurrentRuntime(); + Skip.UnlessLongRunning(); await using var conn = Create(allowAdmin: true, require: RedisFeatures.v2_6_0); @@ -484,6 +492,8 @@ public async Task CompareScriptToDirect() [Fact] public async Task TestCallByHash() { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true, require: RedisFeatures.v2_6_0); const string Script = "return redis.call('incr', KEYS[1])"; @@ -501,16 +511,18 @@ public async Task TestCallByHash() string hexHash = string.Concat(hash.Select(x => x.ToString("X2"))); Assert.Equal("2BAB3B661081DB58BD2341920E0BA7CF5DC77B25", hexHash); - db.ScriptEvaluate(script: hexHash, keys: keys, flags: CommandFlags.FireAndForget); - db.ScriptEvaluate(hash, keys, flags: CommandFlags.FireAndForget); + await db.ScriptEvaluateAsync(script: hexHash, keys: keys); + await db.ScriptEvaluateAsync(hash, keys); - var count = (int)db.StringGet(keys)[0]; + var count = (int)db.StringGet(key); Assert.Equal(2, count); } [Fact] public async Task SimpleLuaScript() { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true, require: RedisFeatures.v2_6_0); const string Script = "return @ident"; @@ -565,6 +577,8 @@ public async Task SimpleLuaScript() [Fact] public async Task SimpleRawScriptEvaluate() { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true, require: RedisFeatures.v2_6_0); const string Script = "return ARGV[1]"; @@ -617,6 +631,8 @@ public async Task SimpleRawScriptEvaluate() [Fact] public async Task LuaScriptWithKeys() { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true, require: RedisFeatures.v2_6_0); const string Script = "redis.call('set', @key, @value)"; @@ -645,6 +661,8 @@ public async Task LuaScriptWithKeys() [Fact] public async Task NoInlineReplacement() { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true, require: RedisFeatures.v2_6_0); const string Script = "redis.call('set', @key, 'hello@example')"; @@ -678,6 +696,8 @@ public void EscapeReplacement() [Fact] public async Task SimpleLoadedLuaScript() { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true, require: RedisFeatures.v2_6_0); const string Script = "return @ident"; @@ -733,6 +753,8 @@ public async Task SimpleLoadedLuaScript() [Fact] public async Task LoadedLuaScriptWithKeys() { + NoConcurrentRuntime(); + await using var conn = Create(allowAdmin: true, require: RedisFeatures.v2_6_0); const string Script = "redis.call('set', @key, @value)"; diff --git a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj index 9a4394656..1604d6905 100644 --- a/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj +++ b/tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj @@ -9,6 +9,9 @@ full enable true + + + $(DefineConstants);BUILD_CURRENT diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index 62b841f08..8f1adb3e9 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -66,6 +66,16 @@ public static void Log(TextWriter output, string message) } } + protected void NoConcurrentRuntime() + { + // Some tests are not amenable to running concurrently in different runtimes - for + // example they might do a script-flush or a flush-db; ensure it only runs against + // our primary build target (or debug, which is local). + #if !(DEBUG || BUILD_CURRENT) + Assert.Skip("Avoiding concurrent runtime; this is not the primary build"); + #endif + } + protected void Log(string? message, params object[] args) { if (args is { Length: > 0 }) From 3192aef8fac8221d00da9b9b5d5d9c1972b77698 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 27 May 2026 11:17:43 +0100 Subject: [PATCH 10/10] more CI stabilization; HOTKEYS this time --- tests/StackExchange.Redis.Tests/HotKeysTests.cs | 12 ++++++++++++ .../InProcessDatabaseUnitTests.cs | 6 +++--- tests/StackExchange.Redis.Tests/TestBase.cs | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/StackExchange.Redis.Tests/HotKeysTests.cs b/tests/StackExchange.Redis.Tests/HotKeysTests.cs index f34831842..4b43b04a0 100644 --- a/tests/StackExchange.Redis.Tests/HotKeysTests.cs +++ b/tests/StackExchange.Redis.Tests/HotKeysTests.cs @@ -15,6 +15,8 @@ public class HotKeysClusterTests(ITestOutputHelper output, SharedConnectionFixtu [InlineData(false)] public void CanUseClusterFilter(bool sample) { + NoConcurrentRuntime(); + var key = Me(); using var muxer = GetServer(key, out var server); Log($"server: {Format.ToString(server.EndPoint)}, key: '{key}'"); @@ -129,6 +131,8 @@ public async Task StopWhenNotRunningIsFalseAsync() [Fact] public void CanStartStopReset() { + NoConcurrentRuntime(); + RedisKey key = Me(); using var muxer = GetServer(key, out var server); server.HotKeysStart(duration: Duration); @@ -215,6 +219,8 @@ private void CheckSimpleWithKey(RedisKey key, HotKeysResult hotKeys, IServer ser [Fact] public async Task CanStartStopResetAsync() { + NoConcurrentRuntime(); + RedisKey key = Me(); await using var muxer = GetServer(key, out var server); await server.HotKeysStartAsync(duration: Duration); @@ -244,6 +250,8 @@ public async Task CanStartStopResetAsync() [Fact] public async Task DurationFilterAsync() { + NoConcurrentRuntime(); + Skip.UnlessLongRunning(); // time-based tests are horrible RedisKey key = Me(); @@ -276,6 +284,8 @@ public async Task DurationFilterAsync() [InlineData(HotKeysMetrics.Network | HotKeysMetrics.Cpu)] public async Task MetricsChoiceAsync(HotKeysMetrics metrics) { + NoConcurrentRuntime(); + RedisKey key = Me(); await using var muxer = GetServer(key, out var server); await server.HotKeysStartAsync(metrics, duration: Duration); @@ -305,6 +315,8 @@ public async Task MetricsChoiceAsync(HotKeysMetrics metrics) [Fact] public async Task SampleRatioUsageAsync() { + NoConcurrentRuntime(); + RedisKey key = Me(); await using var muxer = GetServer(key, out var server); await server.HotKeysStartAsync(sampleRatio: 3, duration: Duration); diff --git a/tests/StackExchange.Redis.Tests/InProcessDatabaseUnitTests.cs b/tests/StackExchange.Redis.Tests/InProcessDatabaseUnitTests.cs index 1e05e971f..6654240a3 100644 --- a/tests/StackExchange.Redis.Tests/InProcessDatabaseUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/InProcessDatabaseUnitTests.cs @@ -5,14 +5,14 @@ namespace StackExchange.Redis.Tests; [RunPerProtocol] -public class InProcessDatabaseUnitTests(ITestOutputHelper output) : TestBase(output) +public class InProcessDatabaseUnitTests(ITestOutputHelper output) { [Fact] public async Task DatabasesAreIsolatedAndCanBeFlushed() { - NoConcurrentRuntime(); + TestBase.NoConcurrentRuntime(); - using var server = new InProcessTestServer(Output); + using var server = new InProcessTestServer(output); await using var conn = await server.ConnectAsync(); var admin = conn.GetServer(conn.GetEndPoints()[0]); diff --git a/tests/StackExchange.Redis.Tests/TestBase.cs b/tests/StackExchange.Redis.Tests/TestBase.cs index 8f1adb3e9..0f3fa9a31 100644 --- a/tests/StackExchange.Redis.Tests/TestBase.cs +++ b/tests/StackExchange.Redis.Tests/TestBase.cs @@ -66,7 +66,7 @@ public static void Log(TextWriter output, string message) } } - protected void NoConcurrentRuntime() + internal static void NoConcurrentRuntime() { // Some tests are not amenable to running concurrently in different runtimes - for // example they might do a script-flush or a flush-db; ensure it only runs against