Fix EXPIRE family double reply for incompatible options and session kill on braces in an option - #2037
Merged
kevin-montrose merged 4 commits intoAug 12, 2026
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes RESP protocol correctness and session stability for the EXPIRE command family option parsing in Garnet’s RESP layer, ensuring invalid option combinations and unsupported options produce exactly one error reply and do not apply any expiry side effects.
Changes:
- Prevents
EXPIREfamily incompatible option pairs (e.g.,NX XX,GT LT) from writing an inline error and then continuing to execute the storage operation (double reply + unintended expiry application). - Fixes unsupported-option reporting to echo the correct token (including when the second option is invalid) and avoids
FormatException/connection drops when options contain{/}by using the correctAbortWithErrorMessageoverload. - Adds targeted tests, including raw-RESP pipelining coverage via
LightClientRequest, to assert “single reply” behavior and no side effects on rejected expiries.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| libs/server/Resp/KeyAdminCommands.cs | Fixes control flow to return on incompatible option pairs and routes unsupported-option errors through safe AbortWithErrorMessage overloads (correct token index, no double-format). |
| test/standalone/Garnet.test/RespTests.cs | Extends option error tests (brace + second-token invalid) and adds a raw-RESP regression test ensuring incompatible options yield a single reply and do not modify key TTL/existence. |
hexonal (hexonal)
force-pushed
the
fix-expire-option-conflict-double-reply
branch
3 times, most recently
from
August 8, 2026 04:49
78bd277 to
1e61c65
Compare
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.
hexonal (hexonal)
force-pushed
the
fix-expire-option-conflict-double-reply
branch
from
August 10, 2026 01:46
1e61c65 to
384001c
Compare
kevin-montrose
approved these changes
Aug 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Symptom
EXPIRE,PEXPIRE,EXPIREATandPEXPIREATanswer an incompatible option pair with the error and then with an integer, so one command produces two RESP replies and the expiry that was just refused is applied anyway.Raw socket against a Debug build of unpatched
main, each command pipelined withPINGso the extra reply is visible (redis-clihides it — it stops after the first reply and silently attributes the leftover to the next command):Same shape for
GT LT(:0),XX XX(:0), and forPEXPIRE/EXPIREAT/PEXPIREAT. Expected in every case: the error alone, key untouched.Two more problems in the same option block:
The second is a
FormatExceptionescaping the command handler. Server log:RespServerSession.TryConsumeMessages's genericcatch (Exception)(RespServerSession.cs:566) disposes the network sender without writing anything, so the whole pipelined batch — including the+OKfor the precedingSET— is lost.Root cause
Double reply.
libs/server/Resp/KeyAdminCommands.cs:413-419. Theelsearm of the option-combination check writes the error inline and falls out of theifblock. Execution continues tostorageApi.EXPIRE(line ~440) withexpireOptionstill holding whatever the first token parsed to, and then writes a second reply. Every path through thatelseproduced two replies; there is no input for which it was correct.Wrong token reported.
KeyAdminCommands.cs:397validates token 3 but formatsparseState.GetString(2).Session kill. Both call sites (lines 390 and 397) pre-format the message and pass the single resulting
stringtoAbortWithErrorMessage. There is nostring→ReadOnlySpan<byte>conversion, so that binds to theparams object[]overload atlibs/server/Resp/Objects/ObjectStoreUtils.cs:99, which runsstring.Formata second time over the already-substituted text. A{or}in the option therefore throws.Note the interaction:
EXPIRE k 100 NX {is safe onmainonly because line 397 formats token 2 (NX). Correcting the index without also removing the second format would have extended the crash to that input.Fix
elsearm nowreturn AbortWithErrorMessage("ERR NX and XX, GT or LT options at the same time are not compatible"u8).(string format, object arg0)overload atObjectStoreUtils.cs:63, and line 397 now reports token 3.return AbortWithErrorMessage(...)is what the five other validation failures in this same method already do (KeyAdminCommands.cs:370,377,382,390,397), and it is how the sibling option-conflict check is written forGEOADDinlibs/server/Resp/Objects/SortedSetGeoCommands.cs:58-62.The wire text is unchanged:
RespWriteUtils.TryWriteError'sReadOnlySpan<byte>overload (libs/common/RespWriteUtils.cs:228) emits'-'+ payload + CRLF exactly as theReadOnlySpan<char>one (line 266) did for this ASCII-only literal.One deliberate behaviour change:
AbortWithErrorMessagesetscommandErrorWritten, which the rawTryWriteErrorit replaces did not. This rejection now counts towardINFO commandstatsfailed_calls(RespServerSession.cs:684-690), consistent with every other validation failure in the method.Tests
New
RespTests.KeyExpireIncompatibleOptionsSingleReplyTestusesLightClientRequestand asserts on raw RESP. StackExchange.Redis cannot observe a duplicated or missing reply — it matches replies positionally and would absorb the extra one — so a raw-RESP client is required. Each command is followed byPING, becauseAssertEqualUpToExpectedLengthonly compares the expected prefix and would pass against a trailing:1otherwise.Fails on unpatched
main:EXPIRE,PEXPIRE,EXPIREAT,PEXPIREATwithNX XX, andEXPIRE ... GT LT— expects-ERR ...not compatible\r\n+PONG\r\n;mainreturns the error plus:1/:0before+PONG.TTL keyA -> :-1after those five rejections (main::100).EXISTS keyB -> :1afterEXPIRE keyB 0 NX XX(main::0, key deleted).EXPIRE keyA 100 NX ZZ -> -ERR Unsupported option ZZ(main:... NX).Regression guards, green on
mainas well:EXPIRE keyA 100 XX GT -> :0andEXPIRE keyA 100 LT XX -> :0— accepted pairs still reach storage and still reply exactly once.EXPIRE keyA 100 NX -> :1— single-option happy path.EXPIRE keyA 100 ZZ -> -ERR Unsupported option ZZ— first-token path, unchanged.EXPIRE keyA -1 NX XX -> -ERR invalid expire time, must be >= 0— argument validation still precedes option validation.Extended
RespTests.KeyExpireBadOptionTests([TestCase("EXPIRE")],[TestCase("PEXPIRE")]), three new assertions, all three fail on unpatchedmain:<cmd> foo 100 {→ERR Unsupported option {. Onmainthe connection is dropped, so the expectedRedisServerExceptionnever arrives.<cmd> foo 100 NX ZZ→ERR Unsupported option ZZ.mainsaysNX.<cmd> foo 100 NX {→ERR Unsupported option {.mainsaysNX.That test has no
EXPIREAT/PEXPIREATcases and none were added; four-command coverage lives in the new raw-RESP test and covers the incompatible-pair path only.Related gaps left unfixed
The unsupported-option token is echoed into the error message verbatim, control bytes included. This is already the case on
mainfor the first option token, and this change makes the second token reachable the same way when it is the one that failed to parse (mainalways echoed the first). It is not a new capability — the first-token form is already reachable onmain— but flagging it so it is not read as introduced here. SanitisingGenericErrUnsupportedOptionis a separate change and is not attempted; I can share a reproduction privately if that is useful for prioritising it.Which pairs are rejected is unchanged. Garnet rejects duplicates such as
XX XXandNX NX, reportsGT LTwith the NX/XX message rather than a dedicated one, and accepts at most two option tokens — all narrower than Redis, which ORs repeated flags and rejects only NX-with-XX/GT/LT and GT-with-LT. This change only stops the second reply; the accept/reject set and the message text are byte-identical tomain.libs/server/Resp/PubSubCommands.cs:430,459,474use the sameAbortWithErrorMessage(string.Format(...))shape and so also format twice, but their arguments are constant strings with no braces, so they cannot throw. Left alone.