Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/common/backed_args.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace cmn {

class BackedArguments {
constexpr static size_t kLenCap = 5;
constexpr static size_t kStorageCap = 88;
constexpr static size_t kStorageCap = 128;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we got this by removing two fields + growing to hit mi_good_size of 320


constexpr static size_t kShrinkFloor = 64 << 10; // 64 KiB

Expand Down Expand Up @@ -130,15 +130,19 @@ class BackedArguments {
storage_.resize(last_offs);
}

// Inlined buffer where the command arguments are stored.
// Can be used as a local buffer to store captured replies
std::span<char> GetInlineBuffer() {
return {storage_.begin(), std::max(kStorageCap, storage_.size())};

@augmentcode augmentcode Bot Jun 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetInlineBuffer() returns a span sized to max(kStorageCap, storage_.size()), which can exceed storage_.size(); writing into that span (as done by reply capture) writes past the vector's constructed elements and can violate container/object-lifetime rules (UB under sanitizers).

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

}
Comment on lines +133 to +137

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Inline buffer overwrites args 🐞 Bug ≡ Correctness

BackedArguments::GetInlineBuffer exposes a span starting at storage_.begin(), but storage_
contains the serialized command argument bytes; CapturingReplyBuilder::SendBulkString writes reply
bytes into this span and stores a string_view (BulkStringRef) into the payload. This can overwrite
command arguments (and any string_views referencing them, e.g. transaction/journaling), and if the
reply string_view aliases the same buffer region the memcpy can have overlap UB.
Agent Prompt
### Issue description
`GetInlineBuffer()` returns a span starting at the beginning of `storage_`, but `storage_` is the backing store for command arguments. Using it as a reply scratch buffer overwrites argument bytes and can invalidate any downstream `std::string_view` references to args.

### Issue Context
`MultiCommandSquasher` passes this buffer into `CapturingReplyBuilder`, and `CapturingReplyBuilder::SendBulkString` writes into the buffer and captures a `BulkStringRef` view into it.

### Fix Focus Areas
- src/common/backed_args.h[133-137]
- src/server/multi_command_squasher.cc[242-248]
- src/facade/reply_capture.cc[53-61]

### Suggested fix
- Change `GetInlineBuffer()` to return only the **unused tail** of the backing storage (spare capacity), not the bytes that currently contain arguments. For example:
  - `auto* base = storage_.data();`
  - `size_t used = storage_.size();`
  - `size_t cap = storage_.capacity();`
  - `return std::span<char>(base + used, cap - used);`
- With this change, reply writes won’t overwrite args and also avoid any overlap with `str.data()` when `str` originates from the args buffer.
- If any remaining aliasing is still possible, switch from `memcpy` to `memmove` or guard against overlap explicitly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


protected:
absl::InlinedVector<uint32_t, kLenCap> offsets_;

// The capacity is chosen so that we allocate a fully utilized (128 bytes) block.
// See CommandContext for storage cap size limits
absl::InlinedVector<char, kStorageCap> storage_;
};

static_assert(sizeof(BackedArguments) == 128);

template <typename I> void BackedArguments::Assign(I begin, I end, size_t len) {
offsets_.resize(len);
size_t total_size = 0;
Expand Down
17 changes: 12 additions & 5 deletions src/facade/dragonfly_connection.cc
Original file line number Diff line number Diff line change
Expand Up @@ -750,9 +750,6 @@ Connection::Connection(Protocol protocol, util::HttpListenerBase* http_listener,
ssl_ctx_(ctx),
service_(service),
flags_(0) {
constexpr size_t kReqSz = sizeof(ParsedCommand);
static_assert(kReqSz <= 256);

// TODO: to move parser initialization to where we initialize the reply builder.
switch (protocol) {
case Protocol::REDIS:
Expand Down Expand Up @@ -2980,7 +2977,7 @@ void Connection::EnqueueParsedCommand(ParsedCommand* cmd) {
}
parsed_tail_ = cmd;

size_t used_mem = cmd->EnqueuedBytes();
size_t used_mem = cmd->UsedMemory();
parsed_cmd_q_len_++;
dispatch_waiting_count_++; // the newly appended tail command is not yet dispatched
parsed_cmd_q_bytes_ += used_mem;
Expand Down Expand Up @@ -3013,7 +3010,7 @@ void Connection::ReleasePipelinedCommand(ParsedCommand* cmd) {
}

void Connection::ReleaseParsedCommand(ParsedCommand* cmd) {
size_t used_mem = cmd->EnqueuedBytes();
size_t used_mem = cmd->UsedMemory();
auto& conn_stats = tl_facade_stats->conn_stats;

DCHECK_GT(parsed_cmd_q_len_, 0u);
Expand Down Expand Up @@ -3042,6 +3039,16 @@ void Connection::ReleaseParsedCommand(ParsedCommand* cmd) {
}
}

void Connection::AdjustParsedCmdBytes(ssize_t delta) {
if (parsed_cmd_q_bytes_ == 0)
return; // command dispatched synchronously, not in pipeline queue
auto& conn_stats = tl_facade_stats->conn_stats;
DCHECK_GE(static_cast<ssize_t>(parsed_cmd_q_bytes_) + delta, 0);
DCHECK_GE(static_cast<ssize_t>(conn_stats.pipeline_queue_bytes) + delta, 0);
parsed_cmd_q_bytes_ += delta;
Comment thread
dranikpg marked this conversation as resolved.
conn_stats.pipeline_queue_bytes += delta;
}
Comment on lines +3042 to +3050

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Queue bytes delta wraps 🐞 Bug ≡ Correctness

Connection::AdjustParsedCmdBytes applies a signed delta to size_t counters via "+=", so negative
deltas will convert/wrap and corrupt pipeline queue byte accounting. The only caller also computes
the delta as (after - before) using size_t, which underflows when memory shrinks and passes the
wrong value downstream.
Agent Prompt
### Issue description
`Connection::AdjustParsedCmdBytes(ssize_t delta)` updates `size_t` counters with `+= delta`, which performs unsigned arithmetic and will wrap for negative deltas. Additionally, `StoreInMultiBlock` passes `after - before` where both are `size_t`, so when `after < before` it underflows before it ever reaches `AdjustParsedCmdBytes`.

### Issue Context
This breaks `pipeline_queue_bytes` / `parsed_cmd_q_bytes_` tracking and can cause backpressure decisions, throttling, and stats to be wrong.

### Fix Focus Areas
- src/facade/dragonfly_connection.cc[2790-2798]
- src/server/main_service.cc[887-895]

### Suggested fix
- In `StoreInMultiBlock`, compute the delta in signed space, e.g. `ssize_t delta = static_cast<ssize_t>(after) - static_cast<ssize_t>(before);`.
- In `AdjustParsedCmdBytes`, apply the delta using a signed intermediate and then cast back:
  - `auto new_bytes = static_cast<ssize_t>(parsed_cmd_q_bytes_) + delta;` (DCHECK >= 0)
  - `parsed_cmd_q_bytes_ = static_cast<size_t>(new_bytes);`
  - same for `conn_stats.pipeline_queue_bytes`.
- Consider also DCHECKing that `delta` fits into the signed range you use (or accept `int64_t`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


void Connection::DestroyParsedQueue() {
ConnectionMemoryTracker memory_tracker(this);
while (parsed_head_ != nullptr) {
Expand Down
5 changes: 5 additions & 0 deletions src/facade/dragonfly_connection.h
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,11 @@ class Connection : public util::Connection {

void MarkForClose();

// Adjusts the pipeline queue byte counter by `delta`.
// Used when command dispatch mutates a ParsedCommand's backing storage
// (e.g. SwapArgs during MULTI/EXEC collection).
void AdjustParsedCmdBytes(ssize_t delta);

protected:
void OnShutdown() override;
void OnPreMigrateThread() override;
Expand Down
11 changes: 0 additions & 11 deletions src/facade/parsed_command.h
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,8 @@ class ParsedCommand : public cmn::BackedArguments {
return sz;
}

size_t EnqueuedBytes() const {
return enqueued_bytes_;
}

void FinalizeParsing() {
parsed_cycle = base::CycleClock::Now();
enqueued_bytes_ = UsedMemory();
}

// Marks this command as having reply stored in its payload instead of being sent directly.
Expand Down Expand Up @@ -207,13 +202,7 @@ class ParsedCommand : public cmn::BackedArguments {
// otherwise, moved asynchronously into reply_payload_
bool is_deferred_reply_ = false;

size_t enqueued_bytes_ = 0;

std::variant<payload::Payload, SuspendedCommand> reply_;
};

#ifdef __linux__
static_assert(sizeof(ParsedCommand) == 240);
#endif

} // namespace facade
14 changes: 12 additions & 2 deletions src/facade/reply_capture.cc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#include "absl/types/span.h"
#include "base/logging.h"
#include "facade/reply_payload.h"
#include "reply_capture.h"

#define SKIP_LESS(needed) \
Expand Down Expand Up @@ -51,7 +52,12 @@ void CapturingReplyBuilder::SendSimpleString(std::string_view str) {

void CapturingReplyBuilder::SendBulkString(std::string_view str) {
SKIP_LESS(ReplyMode::FULL);
Capture(BulkString{string{str}});
if (str.size() < 12 || str.size() > inline_buffer_.size())
return Capture(BulkString{std::string{str}});

memcpy(inline_buffer_.data(), str.data(), str.size());
Capture(BulkStringRef{std::string_view{inline_buffer_.data(), str.size()}});
inline_buffer_ = inline_buffer_.subspan(str.size());
}

void CapturingReplyBuilder::SendVerbatimString(std::string_view str, VerbatimFormat format) {
Expand Down Expand Up @@ -144,6 +150,10 @@ struct CaptureVisitor {
static_cast<RedisReplyBuilder*>(rb)->SendBulkStringBorrowed(bs);
}

void operator()(const payload::BulkStringRef& bs) {
static_cast<RedisReplyBuilder*>(rb)->SendBulkString(bs);
}

void operator()(payload::Null) {
static_cast<RedisReplyBuilder*>(rb)->SendNull();
}
Expand All @@ -164,7 +174,7 @@ struct CaptureVisitor {
}
builder->StartCollection(cp->len, cp->type);
for (auto& pl : cp->arr)
visit(*this, std::move(pl));
visit(*this, pl);
}

SinkReplyBuilder* rb;
Expand Down
8 changes: 8 additions & 0 deletions src/facade/reply_capture.h
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ class CapturingReplyBuilder : public RedisReplyBuilder {
// If an error is stored inside payload, get a reference to it.
static std::optional<ErrorRef> TryExtractError(const Payload& pl);

// Provide inline buffer to avoid allocations. Payload references it - track the lifetime!
void ProvideInlineBuffer(std::span<char> buf) {
inline_buffer_ = buf;
}

private:
// Send payload directly, bypassing external interface. For efficient passing between two
// captures.
Expand All @@ -78,6 +83,9 @@ class CapturingReplyBuilder : public RedisReplyBuilder {

ReplyMode reply_mode_;

// A buffer that can be used to avoid allocations. The lifetime is guaranteed by the user
std::span<char> inline_buffer_;

// List of nested active collections that are being built.
std::stack<std::pair<std::unique_ptr<payload::CollectionPayload>, int>> stack_;

Expand Down
9 changes: 5 additions & 4 deletions src/facade/reply_payload.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,18 @@ using Error = std::unique_ptr<std::pair<std::string, std::string>>;
using Null = std::nullptr_t; // SendNull or SendNullArray

struct CollectionPayload;
struct SimpleString : public std::string {}; // SendSimpleString
struct BulkString : public std::string {}; // SendBulkString
struct SimpleString : public std::string {}; // SendSimpleString
struct BulkString : public std::string {}; // SendBulkString
struct BulkStringRef : public std::string_view {}; // SendBulkString with guaranteed lifetime

struct VerbatimString {
std::string str;
uint8_t format; // RedisReplyBuilder::VerbatimFormat avoided include
};

using Payload = std::variant<std::monostate, Null, Error, long, double, SimpleString, BulkString,
std::unique_ptr<VerbatimString> /* big struct */, cmn::BorrowedString,
std::unique_ptr<CollectionPayload>>;
BulkStringRef, std::unique_ptr<VerbatimString> /* big struct */,
cmn::BorrowedString, std::unique_ptr<CollectionPayload>>;

#if defined(__linux__) && !defined(_LIBCPP_VERSION)
static_assert(sizeof(Payload) == 40);
Expand Down
4 changes: 4 additions & 0 deletions src/server/conn_context.h
Original file line number Diff line number Diff line change
Expand Up @@ -462,4 +462,8 @@ class CommandContext : public facade::ParsedCommand {
const CommandId* cid_ = nullptr;
};

// 320 is a mi_good_size boundary.
// The previous boundary of 256 would require making backed_args buffers much smaller
static_assert(sizeof(CommandContext) == 320);

} // namespace dfly
5 changes: 5 additions & 0 deletions src/server/http_api.cc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include "core/flatbuffers.h"
#include "facade/conn_context.h"
#include "facade/reply_capture.h"
#include "facade/reply_payload.h"
#include "server/conn_context.h"
#include "server/main_service.h"
#include "util/http/http_common.h"
Expand Down Expand Up @@ -120,6 +121,10 @@ struct CaptureVisitor {
absl::StrAppend(&str, JsonEscape(bs));
}

void operator()(const payload::BulkStringRef& bs) {
absl::StrAppend(&str, JsonEscape(bs));
}

void operator()(const unique_ptr<payload::VerbatimString>& vs) {
absl::StrAppend(&str, JsonEscape(vs->str));
}
Expand Down
11 changes: 9 additions & 2 deletions src/server/main_service.cc
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ ABSL_FLAG(bool, multi_exec_squash, true,

ABSL_FLAG(bool, lua_resp2_legacy_float, false,
"Return rounded down integers instead of floats for lua scripts with RESP2");
ABSL_FLAG(uint32_t, multi_eval_squash_buffer, 4096, "Max buffer for squashed commands per script");
ABSL_FLAG(uint32_t, multi_eval_squash_buffer, 8096, "Max buffer for squashed commands per script");

@augmentcode augmentcode Bot Jun 19, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new default multi_eval_squash_buffer value 8096 looks unusual (previously 4096); if the intent was to double to an 8KiB boundary, this might be an accidental typo.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

ABSL_DECLARE_FLAG(bool, primary_port_http_enabled);
ABSL_FLAG(size_t, listpack_max_field_len, 64,
Expand Down Expand Up @@ -883,8 +883,15 @@ void StoreInMultiBlock(ConnectionContext* dfly_cntx, const CommandId* cid,
auto& exec_info = dfly_cntx->conn_state.exec_info;
const size_t old_size = exec_info.GetStoredCmdBytes();

// Moves arguments from parsed_cmd to body.
// SwapArgs inside StoredCmd moves heap storage out of parsed_cmd,
// so we must adjust the pipeline queue accounting for the delta.
size_t before = parsed_cmd->UsedMemory();
exec_info.body.emplace_back(cid, parsed_cmd, tail_index);
size_t after = parsed_cmd->UsedMemory();
if (before != after) {
dfly_cntx->conn()->AdjustParsedCmdBytes(ssize_t(after) - ssize_t(before));
}

exec_info.stored_cmd_bytes += exec_info.body.back().UsedMemory();
exec_info.is_write |= cid->IsJournaled();
ServerState::tlocal()->stats.stored_cmd_bytes += exec_info.GetStoredCmdBytes() - old_size;
Expand Down
7 changes: 7 additions & 0 deletions src/server/multi_command_squasher.cc
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,13 @@ OpStatus MultiCommandSquasher::SquashedHopCb(EngineShard* es, RespVersion resp_v
auto* ctx = &local_cntx;
crb.SetReplyMode(dispatched.reply_mode);

// Allow captured replies to be stored in argument storage.
// Some commands might include arguments in replies, so we have a limited set
if (dispatched.cmd_cntx && dispatched.cid->SupportsAsync())
crb.ProvideInlineBuffer(dispatched.cmd_cntx->GetInlineBuffer());
else
crb.ProvideInlineBuffer({}); // reset buffer

Comment on lines +223 to +228

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or add some flag to CommandId instead?

// With tiered storage enabled, it makes sense to dispatch async commands concurrently
// to allow concurrent disk operations. Tiered futures are only blocked on during replies
bool do_async = es->tiered_storage() && !IsAtomic() && opts_.pipeline_mode &&
Expand Down
Loading