Skip to content

Commit 1e61c65

Browse files
committed
Fix EXPIRE family emitting two replies for incompatible options
NetworkEXPIRE wrote the "NX and XX, GT or LT options at the same time are not compatible" error and then fell through to storageApi.EXPIRE, so a rejected command produced two RESP replies and applied the expiry it had just refused. Measured on a Debug build of 8b329e3 over a raw socket, for EXPIRE, PEXPIRE, EXPIREAT and PEXPIREAT with "NX XX", and for EXPIRE with "GT LT": SET keyA valueA ; EXPIRE keyA 100 NX XX -> -ERR ...not compatible\r\n:1\r\n (two replies for one command) TTL keyA -> :100 (the refused expiry was applied) SET keyB valueB ; EXPIRE keyB 0 NX XX -> -ERR ...not compatible\r\n:1\r\n EXISTS keyB -> :0 (the key was deleted) Return via AbortWithErrorMessage instead, matching the five other validation failures in the same method. The wire text is unchanged: the ReadOnlySpan<byte> overload emits '-' + payload + CRLF exactly as the ReadOnlySpan<char> one did for this ASCII literal. The neighbouring "Unsupported option" message reported the wrong token: it formatted token 2 while validating token 3, so "EXPIRE keyA 100 NX ZZ" answered "-ERR Unsupported option NX" and named the option that was valid. Both call sites now pass the token to AbortWithErrorMessage rather than pre-formatting it. That also fixes a session kill: a single string argument binds to the params object[] overload, which runs string.Format a second time over the already-substituted text, so a brace in the rejected option threw FormatException out of the handler: EXPIRE keyA 100 { -> b'' (no reply, connection closed) EXPIRE keyA 100 { -> -ERR Unsupported option { (after) Pre-formatting only token 2 is why "EXPIRE keyA 100 NX {" escaped this today; correcting the token index without also removing the second format would have extended the crash to it. One further behaviour change worth noting: AbortWithErrorMessage sets commandErrorWritten, which the raw TryWriteError call it replaces did not, so this rejection now counts toward INFO commandstats failed_calls the way every other validation failure in the method already does.
1 parent 4706f3f commit 1e61c65

2 files changed

Lines changed: 90 additions & 6 deletions

File tree

libs/server/Resp/KeyAdminCommands.cs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -387,14 +387,14 @@ private bool NetworkEXPIRE<TGarnetApi>(RespCommand command, ref TGarnetApi stora
387387
{
388388
if (!parseState.TryGetExpireOption(2, out expireOption))
389389
{
390-
return AbortWithErrorMessage(string.Format(CmdStrings.GenericErrUnsupportedOption, parseState.GetString(2)));
390+
return AbortWithErrorMessage(CmdStrings.GenericErrUnsupportedOption, parseState.GetString(2));
391391
}
392392

393393
if (parseState.Count > 3)
394394
{
395395
if (!parseState.TryGetExpireOption(3, out var additionExpireOption))
396396
{
397-
return AbortWithErrorMessage(string.Format(CmdStrings.GenericErrUnsupportedOption, parseState.GetString(2)));
397+
return AbortWithErrorMessage(CmdStrings.GenericErrUnsupportedOption, parseState.GetString(3));
398398
}
399399

400400
if (expireOption == ExpireOption.XX && (additionExpireOption == ExpireOption.GT ||
@@ -412,10 +412,7 @@ private bool NetworkEXPIRE<TGarnetApi>(RespCommand command, ref TGarnetApi stora
412412
}
413413
else
414414
{
415-
while (!RespWriteUtils.TryWriteError(
416-
"ERR NX and XX, GT or LT options at the same time are not compatible", ref dcurr,
417-
dend))
418-
SendAndReset();
415+
return AbortWithErrorMessage("ERR NX and XX, GT or LT options at the same time are not compatible"u8);
419416
}
420417
}
421418
}

test/standalone/Garnet.test/RespTests.cs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2740,6 +2740,93 @@ public void KeyExpireBadOptionTests(string command)
27402740
var exc = ClassicAssert.Throws<RedisServerException>(() => db.Execute(command, "foo", "100", "128"));
27412741
ClassicAssert.AreEqual("ERR Unsupported option 128", exc.Message);
27422742
}
2743+
2744+
// A brace in the rejected option used to reach string.Format a second time, because
2745+
// AbortWithErrorMessage(string.Format(...)) bound to the params overload and re-formatted
2746+
// the already-substituted text. That threw FormatException out of the handler and dropped
2747+
// the connection instead of writing this error.
2748+
{
2749+
var exc = ClassicAssert.Throws<RedisServerException>(() => db.Execute(command, "foo", "100", "{"));
2750+
ClassicAssert.AreEqual("ERR Unsupported option {", exc.Message);
2751+
}
2752+
2753+
// The second option is reported when it is the one that is unsupported. Before this change
2754+
// the first option was echoed back regardless of which one failed to parse.
2755+
{
2756+
var exc = ClassicAssert.Throws<RedisServerException>(() => db.Execute(command, "foo", "100", "NX", "ZZ"));
2757+
ClassicAssert.AreEqual("ERR Unsupported option ZZ", exc.Message);
2758+
}
2759+
2760+
{
2761+
var exc = ClassicAssert.Throws<RedisServerException>(() => db.Execute(command, "foo", "100", "NX", "{"));
2762+
ClassicAssert.AreEqual("ERR Unsupported option {", exc.Message);
2763+
}
2764+
}
2765+
2766+
/// <summary>
2767+
/// An expiry option pair that Garnet rejects must be answered by the error and nothing else,
2768+
/// and must leave the key untouched. The trailing PING is what makes a second reply visible:
2769+
/// LightClientRequest reads raw RESP, while a reply-counting client would absorb the extra
2770+
/// reply and mis-associate every reply that follows it on the connection.
2771+
/// </summary>
2772+
[Test]
2773+
public void KeyExpireIncompatibleOptionsSingleReplyTest()
2774+
{
2775+
using var lightClientRequest = TestUtils.CreateRequest();
2776+
2777+
var incompatibleOptionsResponse = "-ERR NX and XX, GT or LT options at the same time are not compatible\r\n+PONG\r\n";
2778+
var unsupportedOptionResponse = "-ERR Unsupported option ZZ\r\n+PONG\r\n";
2779+
2780+
lightClientRequest.SendCommand("SET keyA valueA");
2781+
2782+
// All four commands share the same option parsing, so all four must reject the pair identically.
2783+
var response = lightClientRequest.SendCommands("EXPIRE keyA 100 NX XX", "PING", 1, 1);
2784+
TestUtils.AssertEqualUpToExpectedLength(incompatibleOptionsResponse, response);
2785+
2786+
response = lightClientRequest.SendCommands("PEXPIRE keyA 100000 NX XX", "PING", 1, 1);
2787+
TestUtils.AssertEqualUpToExpectedLength(incompatibleOptionsResponse, response);
2788+
2789+
response = lightClientRequest.SendCommands("EXPIREAT keyA 99999999999 NX XX", "PING", 1, 1);
2790+
TestUtils.AssertEqualUpToExpectedLength(incompatibleOptionsResponse, response);
2791+
2792+
response = lightClientRequest.SendCommands("PEXPIREAT keyA 99999999999000 NX XX", "PING", 1, 1);
2793+
TestUtils.AssertEqualUpToExpectedLength(incompatibleOptionsResponse, response);
2794+
2795+
response = lightClientRequest.SendCommands("EXPIRE keyA 100 GT LT", "PING", 1, 1);
2796+
TestUtils.AssertEqualUpToExpectedLength(incompatibleOptionsResponse, response);
2797+
2798+
// None of the rejected expiries may have been applied.
2799+
response = lightClientRequest.SendCommand("TTL keyA");
2800+
TestUtils.AssertEqualUpToExpectedLength(":-1\r\n", response);
2801+
2802+
// A rejected expiry of 0 must not delete the key either.
2803+
lightClientRequest.SendCommand("SET keyB valueB");
2804+
2805+
response = lightClientRequest.SendCommands("EXPIRE keyB 0 NX XX", "PING", 1, 1);
2806+
TestUtils.AssertEqualUpToExpectedLength(incompatibleOptionsResponse, response);
2807+
2808+
response = lightClientRequest.SendCommand("EXISTS keyB");
2809+
TestUtils.AssertEqualUpToExpectedLength(":1\r\n", response);
2810+
2811+
// The unsupported option error must name the option that failed to parse, not the one before it.
2812+
response = lightClientRequest.SendCommands("EXPIRE keyA 100 NX ZZ", "PING", 1, 1);
2813+
TestUtils.AssertEqualUpToExpectedLength(unsupportedOptionResponse, response);
2814+
2815+
// The accepted pairs and the sibling error paths keep replying exactly once.
2816+
response = lightClientRequest.SendCommands("EXPIRE keyA 100 XX GT", "PING", 1, 1);
2817+
TestUtils.AssertEqualUpToExpectedLength(":0\r\n+PONG\r\n", response);
2818+
2819+
response = lightClientRequest.SendCommands("EXPIRE keyA 100 LT XX", "PING", 1, 1);
2820+
TestUtils.AssertEqualUpToExpectedLength(":0\r\n+PONG\r\n", response);
2821+
2822+
response = lightClientRequest.SendCommands("EXPIRE keyA 100 NX", "PING", 1, 1);
2823+
TestUtils.AssertEqualUpToExpectedLength(":1\r\n+PONG\r\n", response);
2824+
2825+
response = lightClientRequest.SendCommands("EXPIRE keyA 100 ZZ", "PING", 1, 1);
2826+
TestUtils.AssertEqualUpToExpectedLength(unsupportedOptionResponse, response);
2827+
2828+
response = lightClientRequest.SendCommands("EXPIRE keyA -1 NX XX", "PING", 1, 1);
2829+
TestUtils.AssertEqualUpToExpectedLength("-ERR invalid expire time, must be >= 0\r\n+PONG\r\n", response);
27432830
}
27442831

27452832
#region ExpireAt

0 commit comments

Comments
 (0)