From 073ae7250db28de3d214100cf8e974e59b599551 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 5 Aug 2026 09:51:31 +0100 Subject: [PATCH 1/3] Fix KeyType handling of "none" fix #3156 --- src/StackExchange.Redis/Enums/RedisType.cs | 4 +++- tests/StackExchange.Redis.Tests/KeyTests.cs | 21 +++++++++++++++++++ .../ResultProcessorUnitTests/BasicScalars.cs | 4 ++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/StackExchange.Redis/Enums/RedisType.cs b/src/StackExchange.Redis/Enums/RedisType.cs index 43efcf86b9..25cea0bbd6 100644 --- a/src/StackExchange.Redis/Enums/RedisType.cs +++ b/src/StackExchange.Redis/Enums/RedisType.cs @@ -13,7 +13,9 @@ public enum RedisType /// /// The specified key does not exist. /// - [AsciiHash("")] + /// none is the literal reply from TYPE for a key that does not exist, + /// so this member has a real token (unlike ). + [AsciiHash("none")] None, /// diff --git a/tests/StackExchange.Redis.Tests/KeyTests.cs b/tests/StackExchange.Redis.Tests/KeyTests.cs index 37479a0693..322cbf00ba 100644 --- a/tests/StackExchange.Redis.Tests/KeyTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyTests.cs @@ -54,6 +54,27 @@ public async Task FlushFetchRandomKey() Assert.Equal(prefix + "abc", Encoding.UTF8.GetString(keyBytes)); } + [Fact] + public async Task KeyTypeOfMissingKeyIsNone() // see #3156 + { + await using var conn = Create(); + + var db = conn.GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + + Assert.Equal(RedisType.None, db.KeyType(key)); + Assert.Equal(RedisType.None, await db.KeyTypeAsync(key)); + + db.StringSet(key, "abc", flags: CommandFlags.FireAndForget); + Assert.Equal(RedisType.String, db.KeyType(key)); + Assert.Equal(RedisType.String, await db.KeyTypeAsync(key)); + + db.KeyDelete(key, CommandFlags.FireAndForget); + Assert.Equal(RedisType.None, db.KeyType(key)); + Assert.Equal(RedisType.None, await db.KeyTypeAsync(key)); + } + [Fact] public async Task Zeros() { diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs index 090b223f35..5f6f91ce4c 100644 --- a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs @@ -157,6 +157,10 @@ public class BasicScalars(ITestOutputHelper log) : ResultProcessorUnitTest(log) [InlineData("+set\r\n", Redis.RedisType.Set)] [InlineData("+list\r\n", Redis.RedisType.List)] [InlineData("+stream\r\n", Redis.RedisType.Stream)] + [InlineData("+vectorset\r\n", Redis.RedisType.VectorSet)] + [InlineData("+array\r\n", Redis.RedisType.Array)] + [InlineData("+none\r\n", Redis.RedisType.None)] // TYPE reply for a key that does not exist; see #3156 + [InlineData("$4\r\nnone\r\n", Redis.RedisType.None)] [InlineData("+blah\r\n", Redis.RedisType.Unknown)] [InlineData("$-1\r\n", Redis.RedisType.None)] [InlineData("_\r\n", Redis.RedisType.None)] From 3afcf640b3a56a50b324a8427e088a0d1ff0c1cc Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 5 Aug 2026 09:59:07 +0100 Subject: [PATCH 2/3] fix generator handling of explicit "" --- eng/StackExchange.Redis.Build/AsciiHash.md | 4 +++- .../AsciiHashGenerator.cs | 24 ++++++++++++++++++- src/RESPite/Shared/AsciiHash.cs | 3 +++ src/StackExchange.Redis/Enums/RedisCommand.cs | 6 ++--- src/StackExchange.Redis/Enums/RedisType.cs | 5 +++- 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/eng/StackExchange.Redis.Build/AsciiHash.md b/eng/StackExchange.Redis.Build/AsciiHash.md index 4a76ded62f..ceaf5575e2 100644 --- a/eng/StackExchange.Redis.Build/AsciiHash.md +++ b/eng/StackExchange.Redis.Build/AsciiHash.md @@ -117,7 +117,9 @@ public static partial bool TryParse(ReadOnlySpan value, out SomeEnum value Individual enum members can also be marked with `[AsciiHash("token value")]` to override the token payload. If an enum member declares an empty explicit value (i.e. `[AsciiHash("")]`), then that member is ignored by the tool; this is useful for marking "unknown" or "invalid" enum values (commonly the first enum, which by -convention has the value `0`): +convention has the value `0`). Note that this is about the *explicit* empty token: a member with no attribute +(or a bare `[AsciiHash]`) still infers its token from the member name, so a member that must not be matched +needs the explicit `[AsciiHash("")]`: ``` c# public enum SomeEnum diff --git a/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs b/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs index b00675c400..b41e329efa 100644 --- a/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs +++ b/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs @@ -183,12 +183,34 @@ private static string GetRawValue(string name, AttributeData? asciiHashAttribute } if (string.IsNullOrWhiteSpace(value)) { - value = InferPayload(name); // if nothing explicit: infer from name + // an explicit empty token means "pretend you don't know about this" (usually the zero + // member, i.e. Unknown/None); anything else: infer from the name + value = HasExplicitToken(asciiHashAttribute) ? "" : InferPayload(name); } return value; } + // the attribute's token parameter is optional, so a bare [AsciiHash] and [AsciiHash("")] are + // indistinguishable in ConstructorArguments (both report ""); the syntax tells them apart + private static bool HasExplicitToken(AttributeData? asciiHashAttribute) + { + if (asciiHashAttribute?.ApplicationSyntaxReference?.GetSyntax() + is not AttributeSyntax { ArgumentList.Arguments: { Count: > 0 } arguments }) + { + return false; + } + + foreach (var argument in arguments) + { + // NameEquals is a property assignment (i.e. CaseSensitive = false); anything else is + // the token, whether positional or named (i.e. token: "") + if (argument.NameEquals is null) return true; + } + + return false; + } + private static string InferPayload(string name) => name.Replace("_", "-"); private (string Namespace, string ParentType, Accessibility Accessibility, string Name, diff --git a/src/RESPite/Shared/AsciiHash.cs b/src/RESPite/Shared/AsciiHash.cs index f0f134872e..15164c4faa 100644 --- a/src/RESPite/Shared/AsciiHash.cs +++ b/src/RESPite/Shared/AsciiHash.cs @@ -25,6 +25,9 @@ public sealed partial class AsciiHashAttribute(string token = "") : Attribute /// The token expected when parsing data, if different from the implied value. The implied /// value is the name, replacing underscores for hyphens, so: 'a_b' becomes 'a-b'. /// + /// An explicit empty token (i.e. [AsciiHash("")]) means that the member is + /// excluded, and cannot be parsed or formatted; this is for client-side values such as + /// Unknown. This is distinct from omitting the token, which infers it from the name. public string Token => token; /// diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index 55769105fd..d3e3d44a93 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -6,7 +6,8 @@ namespace StackExchange.Redis; // ReSharper disable InconsistentNaming internal enum RedisCommand { - NONE, // must be first for "zero reasons" + [AsciiHash("")] // not a command; must be first for "zero reasons" + NONE, APPEND, ASKING, @@ -317,9 +318,6 @@ internal static partial class RedisCommandMetadata [AsciiHash(CaseSensitive = false)] public static partial bool TryParseCI(ReadOnlySpan command, out RedisCommand value); - - [AsciiHash] - public static partial bool TryFormat(RedisCommand command, out string format); } // ReSharper restore InconsistentNaming diff --git a/src/StackExchange.Redis/Enums/RedisType.cs b/src/StackExchange.Redis/Enums/RedisType.cs index 25cea0bbd6..3cfd747d3d 100644 --- a/src/StackExchange.Redis/Enums/RedisType.cs +++ b/src/StackExchange.Redis/Enums/RedisType.cs @@ -77,6 +77,8 @@ public enum RedisType /// /// The data-type was not recognised by the client library. /// + /// This is a client-side value, not a server token, so it is not parsable. + [AsciiHash("")] Unknown, /// @@ -98,7 +100,8 @@ public enum RedisType /// internal static partial class RedisTypeMetadata { - [AsciiHash] + // TYPE replies are lower-case, but this is not a hot path, and v2 parsed case-insensitively + [AsciiHash(CaseSensitive = false)] internal static partial bool TryParse(ReadOnlySpan value, out RedisType redisType); } } From 9329a6f86c433b2344e0504752bf5b5ada4be31c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 5 Aug 2026 11:12:42 +0100 Subject: [PATCH 3/3] additional tests to pin the new generator "" behavior --- .../CommandMapUnitTests.cs | 16 +++++++ .../KeyNotificationUnitTests.cs | 17 +++++++ .../RedisTypeMetadataUnitTests.cs | 47 +++++++++++++++++++ .../ResultProcessorUnitTests/BasicScalars.cs | 3 ++ 4 files changed, 83 insertions(+) create mode 100644 tests/StackExchange.Redis.Tests/RedisTypeMetadataUnitTests.cs diff --git a/tests/StackExchange.Redis.Tests/CommandMapUnitTests.cs b/tests/StackExchange.Redis.Tests/CommandMapUnitTests.cs index 9ec876c012..b3c9a7cfd6 100644 --- a/tests/StackExchange.Redis.Tests/CommandMapUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/CommandMapUnitTests.cs @@ -47,4 +47,20 @@ public void TryParseCI_ParsesRealWireName(string name, object expected) Assert.True(RedisCommandMetadata.TryParseCI(bytes, out var fromBytes), $"byte parse failed for '{name}'"); Assert.Equal(expectedCommand, fromBytes); } + + [Theory] + // NONE is a client-side sentinel, not a command, so it carries an explicit empty AsciiHash + // token and must not be parsable in any casing + [InlineData("NONE")] + [InlineData("none")] + [InlineData("None")] + // and something that was never a command, for baseline + [InlineData("NOT_A_COMMAND")] + public void TryParseCI_RejectsNonCommands(string name) + { + Assert.False(RedisCommandMetadata.TryParseCI(name.AsSpan(), out _), $"char parse unexpectedly succeeded for '{name}'"); + + ReadOnlySpan bytes = Encoding.ASCII.GetBytes(name); + Assert.False(RedisCommandMetadata.TryParseCI(bytes, out _), $"byte parse unexpectedly succeeded for '{name}'"); + } } diff --git a/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs index 3109428eb0..1e228908fb 100644 --- a/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyNotificationUnitTests.cs @@ -1767,4 +1767,21 @@ public void SubKeySpaceEvent_MultipleFields_ParsesCorrectly() Assert.Equal("field2", (string?)fieldsList[1]); Assert.Equal("field3", (string?)fieldsList[2]); } + + [Fact] + public void GetRawBytes_Unknown_Throws() + { + // Unknown is a client-side value with no wire token (an explicit empty AsciiHash), so + // there is no channel name to emit: reaching here means something is already wrong + var ex = Assert.Throws(() => + { + _ = KeyNotificationTypeMetadata.GetRawBytes(KeyNotificationType.Unknown).Length; + }); + Assert.Equal("type", ex.ParamName); + + // and the public channel factories that route through it + Assert.Throws(() => RedisChannel.KeyEvent(KeyNotificationType.Unknown)); + Assert.Throws(() => RedisChannel.SubKeyEvent(KeyNotificationType.Unknown)); + Assert.Throws(() => RedisChannel.SubKeySpaceEvent(KeyNotificationType.Unknown, "mykey")); + } } diff --git a/tests/StackExchange.Redis.Tests/RedisTypeMetadataUnitTests.cs b/tests/StackExchange.Redis.Tests/RedisTypeMetadataUnitTests.cs new file mode 100644 index 0000000000..730e7dc19f --- /dev/null +++ b/tests/StackExchange.Redis.Tests/RedisTypeMetadataUnitTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Text; +using Xunit; + +namespace StackExchange.Redis.Tests; + +/// +/// Pure unit tests over the generated token parser; the tokens are the +/// literal replies from TYPE. +/// +public class RedisTypeMetadataUnitTests +{ + [Theory] + [InlineData("none", RedisType.None)] // reply for a key that does not exist; see #3156 + [InlineData("string", RedisType.String)] + [InlineData("list", RedisType.List)] + [InlineData("set", RedisType.Set)] + [InlineData("zset", RedisType.SortedSet)] + [InlineData("hash", RedisType.Hash)] + [InlineData("stream", RedisType.Stream)] + [InlineData("vectorset", RedisType.VectorSet)] + [InlineData("array", RedisType.Array)] + // parsing is case-insensitive (as it was in v2, which used Enum.TryParse with ignoreCase) + [InlineData("NONE", RedisType.None)] + [InlineData("None", RedisType.None)] + [InlineData("ZSet", RedisType.SortedSet)] + [InlineData("VECTORSET", RedisType.VectorSet)] + public void TryParse_KnownTokens(string value, RedisType expected) + { + ReadOnlySpan bytes = Encoding.ASCII.GetBytes(value); + Assert.True(RedisTypeMetadata.TryParse(bytes, out var actual), $"parse failed for '{value}'"); + Assert.Equal(expected, actual); + } + + [Theory] + // Unknown is a client-side value rather than a server token, so it is excluded from the parser + [InlineData("unknown")] + [InlineData("Unknown")] + [InlineData("")] + [InlineData("blah")] + [InlineData("nonex")] + public void TryParse_RejectsNonTokens(string value) + { + ReadOnlySpan bytes = Encoding.ASCII.GetBytes(value); + Assert.False(RedisTypeMetadata.TryParse(bytes, out _), $"parse unexpectedly succeeded for '{value}'"); + } +} diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs index 5f6f91ce4c..98510c826d 100644 --- a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs @@ -161,6 +161,9 @@ public class BasicScalars(ITestOutputHelper log) : ResultProcessorUnitTest(log) [InlineData("+array\r\n", Redis.RedisType.Array)] [InlineData("+none\r\n", Redis.RedisType.None)] // TYPE reply for a key that does not exist; see #3156 [InlineData("$4\r\nnone\r\n", Redis.RedisType.None)] + [InlineData("+NONE\r\n", Redis.RedisType.None)] // parsing is case-insensitive + [InlineData("+ZSet\r\n", Redis.RedisType.SortedSet)] + [InlineData("+unknown\r\n", Redis.RedisType.Unknown)] // not a server token: unparsable, hence Unknown [InlineData("+blah\r\n", Redis.RedisType.Unknown)] [InlineData("$-1\r\n", Redis.RedisType.None)] [InlineData("_\r\n", Redis.RedisType.None)]