Fix integer overflow in RESP output buffer growth (HGETALL on large hashes)#1945
Fix integer overflow in RESP output buffer growth (HGETALL on large hashes)#1945hexonal wants to merge 7 commits into
Conversation
…oft#1616) HGETALL (and any command writing a large RESP response through RespMemoryWriter) could crash once the response's output buffer, while doubling to accommodate the data, grew past ~2^30-2^31 bytes. The buffer-growth calculation in ReallocateOutput did its arithmetic in int/uint and only special-cased an exact match on 0x40000000, so once the buffer's starting capacity wasn't a power of two, growth could silently wrap around into a value smaller than what had already been written, causing a confusing ArgumentOutOfRangeException/ OverflowException deep inside Buffer.MemoryCopy or the length check. Extract the growth calculation into RespMemoryWriter.ComputeGrowth, do the arithmetic in long/ulong so it can't wrap, and clamp the result to Array.MaxLength (the true ceiling for a single managed buffer). If a single response genuinely cannot fit even at that ceiling, raise a clear GarnetException (returned to the client as a RESP error, same as other Garnet-level failures) instead of an opaque framework exception that kills the connection with no response at all. Testing performed: - Added RespMemoryWriterTests covering the exact boundary values (0x40000000, 0x60000000, near Array.MaxLength, etc.) that the old arithmetic mishandled; verified these specific inputs throw under a reproduction of the old logic and pass under the new ComputeGrowth. - Added CanDoHGETALLOnLargeHashRequiringManyBufferGrowths, an end-to-end HGETALL test against a real Garnet server with 50,000 fields that forces many real buffer reallocations through the actual write path. - Ran the full existing RespHashTests suite (94 tests) and the Garnet.test Resp-namespace suite (752 tests) - all pass. - Did not reproduce the original workload (multi-million-element hash reaching multi-GB serialized output) end-to-end; that scale is impractical to run in CI/locally, so the fix is verified at the unit level (the exact arithmetic boundaries) plus a smaller-scale integration test of the same code path.
Found during self-review: once output.Length is already clamped at Array.MaxLength, ComputeGrowth legitimately returns the same value (there's nowhere further to grow), which the previous guard (length < bytesWritten) didn't catch since length == bytesWritten in that case. That let ReallocateOutput rent an identically-sized buffer forever, spinning in the caller's `while (!Try...) ReallocateOutput()` loop instead of failing. Explicitly detect "no growth occurred" and raise the same GarnetException in that case. Testing: reran RespMemoryWriterTests (15), RespHashTests (95), and the full Garnet.test Resp-namespace suite (752 passed / 2 pre-existing skips) - all pass with this change.
There was a problem hiding this comment.
Pull request overview
This PR addresses a crash when generating very large RESP responses (reported via HGETALL on multi-million-element hashes) by fixing integer overflow/wraparound in RespMemoryWriter’s output-buffer growth logic. The fix applies to the shared RESP writing path used by many large-response commands, and adds both unit and integration regression coverage.
Changes:
- Refactors output buffer growth into
RespMemoryWriter.ComputeGrowth(...)usinglongarithmetic and clamping toArray.MaxLength, and adds explicit failure behavior when growth is impossible. - Adds unit tests for boundary/overflow scenarios around 2³⁰–2³¹ and
Array.MaxLength. - Adds an end-to-end regression test that forces many reallocations and validates HGETALL correctness on a large hash.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| libs/common/RespMemoryWriter.cs | Reworks buffer growth calculation to avoid overflow and throws a clearer error when a single-buffer response cannot be produced. |
| test/standalone/Garnet.test/Resp/RespMemoryWriterTests.cs | Adds targeted unit tests for buffer growth boundary conditions and monotonicity. |
| test/standalone/Garnet.test.collections/RespHashTests.cs | Adds an integration regression test that exercises the real RESP write path via HGETALL on a large hash. |
| if (length <= 0 || length < bytesWritten || length <= previousLength) | ||
| { | ||
| length <<= 1; | ||
| throw new GarnetException( | ||
| $"RESP response of at least {(long)bytesWritten + extraLenHint} bytes exceeds the maximum " + | ||
| $"supported single-buffer size ({Array.MaxLength} bytes). Consider using a cursor-based " + | ||
| "command (e.g. HSCAN instead of HGETALL) to retrieve large collections in batches."); |
There was a problem hiding this comment.
Good catch, fixed in df7cf71 — this error is a client-visible, request-level condition (this one response exceeds Array.MaxLength), not server-state corruption, so there's no real reason to kill the connection.
Checked the actual throw site: it happens before any buffer is rented or pointer reassigned in ReallocateOutput, and the RespMemoryWriter for the failing command is always used in a using block (see the call sites in HashObjectImpl.cs etc.), so nothing shared across commands on the connection is left inconsistent when the exception unwinds. Added disposeSession: false and a comment explaining why it's safe.
For test coverage: the general "disposeSession: false keeps the connection usable for the next command" mechanism (the part of RespServerSession.cs this change actually exercises) is already covered end-to-end by the existing GarnetObjectStoreDisabledError test, which hits the identical code path via a different GarnetException call site. I didn't add a new end-to-end repro specifically for this trigger, since reaching it for real requires an actual response near Array.MaxLength (~2GB) — impractical for a fast unit test and consistent with this PR's own disclosed limitation that the original multi-GB-scale repro wasn't run end-to-end either. Let me know if you'd want that covered some other way (e.g. a seam to inject a smaller ceiling for testing).
Comment indentation off by one space, and missing trailing newline at EOF.
|
Pushed a follow-up commit fixing a For visibility on the other 3 red checks from the previous run — none touch the code this PR changes:
Will keep an eye on the new run in case any of these are non-flaky. |
Per Copilot review feedback: this error is a client-visible, request-level condition (the response for one command would exceed Array.MaxLength), not server-state corruption, so there is no need to tear down the connection. The throw happens before any buffer is rented or pointer reassigned, and the failing command's RespMemoryWriter is always scoped to a using block, so nothing shared across commands on the connection is left inconsistent. Signed-off-by: hexonal <w741069229@163.com>
…o fix-1616-hgetall-large-hash
|
CI on the latest commit (df7cf71) has 2 unrelated failures, both outside this PR's diff (which only touches
Both showed exactly 1 failure out of their respective suites (983 and 52 tests), everything else green. |
|
It looks like this change would be sufficient: --- a/libs/common/RespMemoryWriter.cs
+++ b/libs/common/RespMemoryWriter.cs
@@ -507,7 +507,7 @@ namespace Garnet.common
}
if (length <= 0)
- throw new OverflowException("length");
+ throw new GarnetException(<some error message>, disposeSession: false);
var newMem = MemoryPool<byte>.Shared.Rent(length);
var newPtrHandle = newMem.Memory.Pin();And add a test that overflows - back of the envelope, with 20K value length, you'd need 250K-ish hash members. Something like: [Test]
public async Task HGETALLMemoryOverflowAsync()
{
const string Key = nameof(HGETALLMemoryOverflowAsync);
const int NumFields = 250_000;
const int FieldLength = 20_000;
using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig());
var db = redis.GetDatabase();
var fieldValue = new string('v', FieldLength); // pad each value so total payload is sizeable
var writeTasks = new Task<bool>[NumFields];
for (var i = 0; i < NumFields; i++)
{
writeTasks[i] = db.HashSetAsync(Key, $"field:{i}", fieldValue);
}
var writeReses = await Task.WhenAll(writeTasks).ConfigureAwait(false);
ClassicAssert.IsTrue(writeReses.All(static x => x));
var x = await db.HashGetAllAsync(Key).ConfigureAwait(false);
var exc = ClassicAssert.ThrowsAsync<RedisServerException>(() => db.HashGetAllAsync(Key));
ClassicAssert.AreEqual(<error message>, exc.Message);
} |
kevin-montrose correctly pointed out that CanDoHGETALLOnLargeHashRequiringManyBufferGrowths doesn't fail on unpatched main - at ~5MB total payload it never gets close to the buffer capacities (~2^30-2^31 bytes) where the pre-fix growth arithmetic broke down. - Add ComputeGrowth_AtMaxCapacity_ReportsNoFurtherGrowthPossible, a fast unit test directly against the growth arithmetic verifying that once the buffer is already at Array.MaxLength, ComputeGrowth correctly reports "no further growth possible" (the signal ReallocateOutput's `length <= previousLength` check relies on to raise a clean GarnetException instead of looping or overflowing). Verified via revert-then-confirm that this test actually catches a regression if the Array.MaxLength clamp is removed. - Add ComputeGrowth_LargeLengthAndLargeSingleItem_NeverShrinksOrOverflows, covering the case where both the current buffer capacity and the incoming single item are simultaneously large, using boundary values that stay within int's valid range (a real extraLenHint can never exceed int.MaxValue). - Correct CanDoHGETALLOnLargeHashRequiringManyBufferGrowths's docstring to not overclaim it as microsoft#1616 regression coverage, and point to the new unit test instead.
|
You're right that On the proposed fix: I did the arithmetic out fully rather than just eyeballing it. Two things came out of that which I think are worth sharing:
Given (1), I don't think the fix itself needs to change. For test coverage, instead of the ~5GB end-to-end test, I added a fast unit test directly against |
Fixes #1616
Root cause
RespMemoryWriter.ReallocateOutput(the buffer-growth routine backing everyWrite*method, including the onesHashGetAlluses for HGETALL) computed its next buffer capacity withint/uintarithmetic and only special-cased an exact match on0x40000000(2^30) to avoid overflow:Once the output buffer's capacity grows large enough (which happens for HGETALL on hashes with enough elements/data that the response requires many buffer doublings) and its size doesn't happen to land exactly on
0x40000000on the way up — which is the common case, since the buffer's starting size isn't guaranteed to be a power of two —length <<= 1can overflow a 32-bit signed int and wrap around to a small or negative value. The subsequentBuffer.MemoryCopy(ptr, newPtr, length, bytesWritten)then receives alengthsmaller thanbytesWritten, which throwsArgumentOutOfRangeExceptionfor thesourceBytesToCopyparameter — exactly the crash reported in the issue. (Other wrap patterns in the same code hit anOverflowException("length")instead; both are opaque, unhandled-by-design exceptions that kill the client connection with no response at all, sinceRespServerSession.ProcessMessages's genericcatch (Exception)handler doesn't send anything back to the client.)Fix
RespMemoryWriter.ComputeGrowth(currentLength, extraLenHint, lowerMinimum), doing all arithmetic inlong/ulongso it can no longer silently wrap around into a value smaller than what's required. The result is clamped toArray.MaxLength, the true ceiling for a single managed array /MemoryPool<byte>.Shared.Rentbuffer.ReallocateOutputnow checks that the computed length can actually hold what's already been written and that real growth occurred (guarding the edge case where the buffer is already atArray.MaxLengthand can't grow further, which would otherwise spin forever re-renting an identically-sized buffer). If the response genuinely can't fit in one buffer, it raises aGarnetExceptionwith a clear, actionable message pointing at cursor-based alternatives (e.g.HSCANinstead ofHGETALL) — this is returned to the client as a normal RESP error instead of crashing the connection.This is a general fix to the shared RESP output-buffer growth path (used by every large-response command, not just HGETALL specifically), but it directly addresses the HGETALL crash reported in #1616.
Testing performed
RespMemoryWriterTests(test/standalone/Garnet.test/Resp/RespMemoryWriterTests.cs), unit tests exercisingComputeGrowthat the exact boundary values the old arithmetic mishandled (0x40000000,0x40000001,0x60000000, values nearArray.MaxLength, and a full repeated-doubling walk from a non-power-of-two starting size up to the ceiling). I independently reproduced the old formula in a scratch project and confirmed these specific inputs throw under the old logic and pass under the new one.CanDoHGETALLOnLargeHashRequiringManyBufferGrowths(test/standalone/Garnet.test.collections/RespHashTests.cs), an end-to-end test against a real Garnet server with 50,000 hash fields that forces many real buffer reallocations through the actual write path, and verifies HGETALL returns the complete, correct data.RespHashTestssuite (95 tests incl. the new one) and theGarnet.testResp-namespace suite (752 tests, 2 pre-existing/unrelated skips) — all pass.