Skip to content

Commit 8c90e8b

Browse files
Fix EXPIRE family emitting two replies for incompatible options (#2037)
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. Co-authored-by: kevin-montrose <kmontrose@microsoft.com>
1 parent 7062722 commit 8c90e8b

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)