Cap HRANDFIELD/ZRANDMEMBER count before packing it into arg1 - #2038
Cap HRANDFIELD/ZRANDMEMBER count before packing it into arg1#2038hexonal (hexonal) wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes an integer overflow bug in the RESP-layer argument packing used by HRANDFIELD and ZRANDMEMBER when a large positive count is shifted into a single int alongside metadata bits. By capping the count to the largest value that can be round-tripped through the packed field, the commands no longer desynchronize connections (missing reply) or trigger huge allocations / response-size-limit errors for specific large counts.
Changes:
- Clamp
paramCounttoint.MaxValue >> 2before packing it with metadata bits forHRANDFIELDandZRANDMEMBER. - Add LightClient-based regression tests that cover previously-broken large-count regimes and verify replies remain correctly framed when pipelined with
PING.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| libs/server/Resp/Objects/SortedSetCommands.cs | Caps ZRANDMEMBER count prior to packing into arg1 to avoid Int32 overflow during bit packing. |
| libs/server/Resp/Objects/HashCommands.cs | Caps HRANDFIELD count prior to packing into arg1 to avoid Int32 overflow during bit packing. |
| test/standalone/Garnet.test.collections/RespSortedSetTests.cs | Adds LightClient regression test validating large-count ZRANDMEMBER responses are complete/framed under pipelining. |
| test/standalone/Garnet.test.collections/RespHashTests.cs | Adds LightClient regression test validating large-count HRANDFIELD responses are complete/framed under pipelining. |
b6bd656 to
2ca2593
Compare
| } | ||
| } | ||
|
|
||
| // The line below packs the count above two metadata bits, so a count above |
There was a problem hiding this comment.
The change is mostly fine, the comment here is overkill and kinda confusing.
Change it (and the one in SortedSetCommands) to something like:
// We reserve to 2 bits of metadata (1 for "include count", 1 for "include scores") in ObjectInput.arg0, so cap count to what will fit in 30-bits.| // with an empty array for it, whereas clamping up to int.MinValue >> 2 would instead route | ||
| // it through Math.Abs into a ~2GB index allocation. Aligning negative counts with Redis, | ||
| // which streams them in batches, is a separate change. | ||
| paramCount = Math.Min(paramCount, int.MaxValue >> 2); |
There was a problem hiding this comment.
This may be a problem with the existing code, but int instead of uint is strange here, seems like we're leaving a bit on the table.
Can you explore a max count of (int)(uint.MaxValue >> 2)? May require some changes in the *Impl.cs files
2ca2593 to
569e046
Compare
|
Comments shortened to your wording (in both On exploring Reaching a 30-bit magnitude requires unpacking with
Getting 30 usable magnitude bits would mean carrying the sign somewhere other than |
569e046 to
c13d9c4
Compare
HRANDFIELD and ZRANDMEMBER pack the user-supplied count above two
metadata bits into the single int the object store receives as arg1.
The shift is unchecked, so any count above int.MaxValue >> 2 overflows
Int32 and unpacks on the storage side to an unrelated value.
Measured against a 3-field hash and a 3-member sorted set:
HRANDFIELD key 1073741824 unpacks to 0, replies *0
HRANDFIELD key 2147483647 unpacks to -1, replies a single field
HRANDFIELD key 536870912 unpacks to -536870912, stalls for the best
part of a minute on a ~2GB allocation, then
errors with "Exceeded maximum response size"
ZRANDMEMBER key 1073741824 unpacks to 0, for which ZRANDMEMBER writes
no reply at all and the RESP stream
desynchronises
ZRANDMEMBER key 2147483647 unpacks to -1, replies a single member
ZRANDMEMBER key 536870912 same stall and error as HRANDFIELD
Cap the count at int.MaxValue >> 2 before the shift. That value packs to
2147483647 and unpacks back to itself, so no count already inside the
representable range changes behaviour, and the object layer saturates
any positive count to the collection size anyway - all six commands
above now reply with the whole collection, immediately.
The cap is deliberately one-sided; counts below int.MinValue >> 2 are
left exactly as they are today. They also fail to round-trip, and for
ZRANDMEMBER they fail the same way this change fixes on the positive
side: measured with a pipelined PING sentinel, ZRANDMEMBER key
-1073741824 and -2147483648 return only the PONG, so that command still
writes no reply at all for those counts. HRANDFIELD instead replies *0.
Clamping them up to int.MinValue >> 2 would not fix either case, it
would replace an instant reply with the ~55s, ~2GB stall described
above, on roughly 1.6 billion inputs this change does not otherwise
touch. Handling negative counts properly means following Redis and
streaming them in batches instead of materialising an index array,
which is a separate change.
c13d9c4 to
09ee6b1
Compare
|
Rebased onto current I also corrected the follow-up proposal itself, because relaxing the header guard is not sufficient. // HashObjectImpl.cs:127
if (count == 0) // This can happen because of expiration but RMW operation haven't applied yet
{
writer.WriteEmptyArray();
output.result1 = 0;
return;
}Giving On the red check: Separately, while triaging a different red leg on #2050 I traced a process-level |
HRANDFIELDandZRANDMEMBERreturn the wrong thing for any count above 536,870,911. Against a 3-field hash and a 3-member sorted set:The
ZRANDMEMBER key 1073741824case is the worst of the six: the client blocks until the next command's reply arrives and then reads it as the answer toZRANDMEMBER, so every subsequent reply on that connection is off by one. The stall cases hold the session for the duration of a ~2GB allocation. The session survives all six; nothing here kills a connection or throwsOutOfMemoryException.Cause
Both commands pack the user-supplied count above two metadata bits into the single
intthe object store receives asarg1:libs/server/Resp/Objects/HashCommands.cs:249libs/server/Resp/Objects/SortedSetCommands.cs:851The shift is unchecked, so any count above
int.MaxValue >> 2(536,870,911) wraps. The storage side unwinds it with an arithmetic>> 2atHashObjectImpl.cs:115andSortedSetObjectImpl.cs:646and gets an unrelated number. Three regimes:-2147483648,-1073741824,0and1073741824.0is short-circuited at the RESP layer, so the only positive count affected is1073741824.2147483647becomes-1, which the object layer reads as "1 element, repeats allowed".536870912becomes-536870912.[2^30, 1610612736)the result iscount - 2^30.The
-536870912case is what stalls:indexCountisMath.Abs(count), soHashObjectImpl.cs:140andSortedSetObjectImpl.cs:665allocatenew int[536870912](~2GB), fill it, and then try to write 536 million bulk strings, which trips the response-size ceiling.The count-of-0 case only desynchronises for
ZRANDMEMBER.HashObjectImpl.cs:146writes the array header unconditionally, soHRANDFIELDemits*0.SortedSetObjectImpl.cs:659writes it only underarrayLength > 1 || (arrayLength == 1 && includedCount), and a count of 0 satisfies neither, so no bytes are produced at all.Fix
One line in each command, before the shift:
536870911 * 4 + 3 == int.MaxValueexactly, so the cap value and both metadata bits round-trip with no room to spare. The object layer already saturates any positive count to the collection size (HashObjectImpl.cs:135,SortedSetObjectImpl.cs:652), so a capped count replies with the whole collection, which is what these six commands should have returned all along.The in-repo sibling is
SRANDMEMBER, which passes the count straight through asarg1with no metadata bits (SetCommands.cs:655) and therefore has no equivalent overflow:SRANDMEMBER s 2147483647already returns the whole set. This bringsHRANDFIELDandZRANDMEMBERin line with it.Surface of the behaviour change:
Math.Minis a no-op for every count at or below 536,870,911, so nothing below the cap moves.[2^30, 1610612736)unpacked tocount - 2^30, and whenever that remainder already exceeded the collection size the reply was correct. Those replies are byte-identical after the cap, because both the old remainder and the new cap saturate to the same collection size. Every other count above the cap was wrong and is now right.Tests
CanDoHRANDFIELDWithOverflowingCountLCinRespHashTests.csandCanUseZRandMemberWithOverflowingCountinRespSortedSetTests.cs. Both useLightClientRequestwith aPINGpipelined behind every command, so a short, missing or duplicated reply is caught rather than absorbed. StackExchange.Redis cannot observe theZRANDMEMBERmissing-reply case at all, which is why these are raw RESP.Four assertions per test do not pass on unpatched main:
107374182453687091221474836472147483647withWITHVALUES/WITHSCORESSeven per test are green regression guards that pass with or without the production change: count
536870911(the boundary the cap must not move),2,5,-5,0, no count at all, and2withWITHVALUES/WITHSCORES.One note on how the red cases fail:
LightClientRequest.CompletePendingRequestswaits for a token count, so on unpatched main these four stall the request rather than raising an assertion. They do not pass; they do not fail fast either.Garnet.test.collections: 750 passed, 0 failed.dotnet format --verify-no-changesclean.Known gap, deliberately not fixed here
ZRANDMEMBER key -1073741824andZRANDMEMBER key -2147483648, with or withoutWITHSCORES, also unpack to 0 and so still write no reply at all. That is unchanged by this PR, and it is the same desynchronisation described above.The cap cannot fix it. Capping the negative side at
int.MinValue >> 2(-536870912) sends those two inputs into theMath.Abs(count)index array instead, replacing an instant desync with a ~2GB allocation, a stall of tens of seconds and anExceeded maximum response sizeerror, on 1.6 billion inputs this change does not otherwise touch. I measured both shapes before settling on the one-sided cap.The right fix is in the object layer, and on a closer look it is not the one-liner I first described.
HRANDFIELDhas an explicit early exit atHashObjectImpl.cs:127—if (count == 0) { WriteEmptyArray(); return; }, commented "This can happen because of expiration but RMW operation haven't applied yet" — andSortedSetRandomMemberhas no equivalent. Giving it that same early exit closes three shapes at once: the overflow-to-0 case above,ZRANDMEMBER key 3against a sorted set whose members have all expired but not yet been collected (Count()returns 0 while the object is still present, so the read returns OK and zero bytes are written), andZRANDMEMBER key -3on that same key, which currently writes an array header and then indexes an empty set. Merely relaxing the header guard atSortedSetObjectImpl.cs:659would not cover the last two. I left it out to keep this change at the RESP layer and off a file two other open PRs touch, and because the expiration half is a live bug on main independent of any overflow — happy to fold it in here, or to file it separately, whichever you prefer.HRANDFIELDhas no equivalent gap:HashObjectImpl.cs:146writes the array header unconditionally, soHRANDFIELD key -1073741824andHRANDFIELD key -2147483648reply*0immediately, identical to main.Separately, bringing negative counts fully in line with Redis, which streams them in batches instead of materialising an index array of
|count|entries, is a larger change and out of scope here.Environment
macOS arm64, net10.0 Debug. Reproductions driven over raw RESP against a local server on 127.0.0.1.