First issue binbin assigned directly to me — I used it to dig into this code path.
Original issue: https://github.com/valkey-io/valkey/issues/4231
1. Business scenario #
One TCP connection does two things:
SUBSCRIBE wiretest— becomes a channel subscriberPUBLISH wiretest <large payload>— is also the publisher
Valkey delivers a copy to every subscriber, including the publisher itself.
SignalR Redis backplane and some multiplexer clients use “same connection pub+sub”, so they hit this.
A connection that only subscribes (never publishes) gets correct delivery. Only the publisher’s own copy is broken.
2. Protocol layer: what the client sees on the wire #
Valkey speaks RESP. Bytes from read() are a stream of type tags + payloads that must be parsed in order.
RESP2 pubsub delivery is a normal array:
*3\r\n
$7\r\nmessage\r\n
$8\r\nwiretest\r\n
$16384\r\nXXXX...\r\n
RESP3 uses push (>) for “server-initiated push, not a reply to a command”:
>3\r\n
$7\r\nmessage\r\n
$8\r\nwiretest\r\n
$16384\r\nXXXX...\r\n
On the same connection, PUBLISH itself also has an integer reply (receiver count):
:1\r\n
Clients (e.g. StackExchange.Redis) track “what reply this command expects”. They want:
:1\r\n ← PUBLISH 的回复
>3\r\n$message\r\n$channel\r\n$payload\r\n ← 推送
If they first see a bare $16384\r\n..., they treat it as the PUBLISH reply → error and desync with the rest of the stream.
3. Server side: per-connection outbound queue #
Each client has roughly three write-related pieces (see client in src/server.h):
| Structure | Role |
|---|---|
c->buf + c->bufpos |
Small replies pile into this static buffer (~16KB, PROTO_REPLY_CHUNK_BYTES) |
c->reply |
list of clientReplyBlocks when the static buffer overflows |
server.pending_push_messages |
Global temporary list: during the current command, pushes meant for “self” land here first |
Write order (write / writev): roughly c->buf first, then c->reply in list order.
Whoever entered buf/reply first goes on the socket first.
Typical event-loop path:
read(query) → 解析命令 → 执行 → 把回复塞进 c->buf / c->reply
→ 事件循环稍后 writev(fd, iov...) 把缓冲刷到内核
4. Pub/Sub delivery: from PUBLISH to bytes #
Command entry in src/pubsub.c:
void publishCommand(client *c) {
if (server.sentinel_mode) {
sentinelPublishCommand(c);
return;
}
int receivers = pubsubPublishMessageAndPropagateToCluster(c->argv[1], c->argv[2], 0);
if (!server.cluster_enabled) forceCommandPropagation(c, PROPAGATE_REPL);
addReplyLongLong(c, receivers);
}
Order matters:
- First
pubsubPublishMessage...— enqueue push for all subscribers - Then
addReplyLongLong(c, receivers)— write:Nto the current client
Delivery core:
int pubsubPublishMessageInternal(robj *channel, robj *message, pubsubtype type) {
...
while (hashtableNext(&iter, &c)) {
addReplyPubsubMessage(c, channel, message, *type.messageBulk);
...
receivers++;
}
Assemble one push frame:
void addReplyPubsubMessage(client *c, robj *channel, robj *msg, robj *message_bulk) {
struct ClientFlags old_flags = c->flag;
c->flag.pushing = 1;
if (c->resp == 2)
addReply(c, shared.mbulkhdr[3]);
else
addReplyPushLen(c, 3);
addReply(c, message_bulk);
addReplyBulk(c, channel);
if (msg) addReplyBulk(c, msg);
if (!old_flags.pushing) c->flag.pushing = 0;
}
Semantically four consecutive writes:
>3\r\n(or RESP2*3)message- channel name (bulk)
- payload (bulk)
c->flag.pushing = 1 tells the reply API: “this is a push, not an ordinary command reply”.
5. Why pending_push_messages (defer)
#
If the publisher is the current connection and the push is written into c->buf mid-PUBLISH, you get:
[push 半帧或整帧] [ :1 ] [或许更多]
Worse with MULTI/EXEC: pushes interleaved among command replies break client state machines.
So Valkey’s rule:
When delivering a push to the client currently executing a command, do not put it on the formal reply queue yet — park it in
pending_push_messages; after the command finishes and its reply is written,listJoinit onto the end ofc->reply.
Logic in _addReplyToBufferOrList in src/networking.c:
/* If we're processing a push message into the current client (i.e. executing PUBLISH
* to a channel which we are subscribed to, then we wanna postpone that message ... */
int defer_push_message = c->flag.pushing && c == server.current_client && server.executing_client &&
!cmdHasPushAsReply(server.executing_client->cmd);
...
if (defer_push_message) {
_addReplyProtoToList(c, server.pending_push_messages, s, len);
return;
}
Conditions:
c->flag.pushing: assembling a push (addReplyPubsubMessageset this)c == server.current_client: push recipient is the command issuer (self-publish)- Current command is not a SUBSCRIBE-family command (those use push as their “reply”)
After the command:
if (!server.execution_nesting) listJoin(c->reply, server.pending_push_messages);
Ideal timeline:
执行 PUBLISH
├─ 给自己的 push 各段 → pending_push_messages
└─ :1 → c->buf / c->reply
afterCommand
└─ pending 接到 c->reply 尾部
写出顺序: :1 → 完整 >3 push
Delivery to others has c != current_client, so no defer — the whole frame goes straight into their buf/reply and order is naturally correct.
6. Reply Copy Avoidance: the 9.0 optimization #
Memcpy’ing large bulk strings into the reply buffer wastes CPU/memory bandwidth.
#2078 added “copy avoidance”:
- Reply buffer stores a small
bulkStrRef { robj *obj; char *str; }, not the string body - On real
writev, iovecs point into the object’s sds memory
Expand on write (see src/networking.c):
static void addBulkStringToReplyIOV(char *buf, size_t buf_len, replyIOV *reply, bufWriteMetadata *metadata) {
bulkStrRef *str_ref = (bulkStrRef *)buf;
while (buf_len > 0 && !reply->limit_reached) {
size_t str_len = sdslen(str_ref->str);
/* RESP encodes bulk strings as $<length>\r\n<data>\r\n */
...
addPlainBufferToReplyIOV(... prefix "$len\r\n" ...);
addPlainBufferToReplyIOV(str_ref->str, str_len, ...); /* 直接指对象内存 */
addPlainBufferToReplyIOV(reply->crlf, 2, ...);
Entry in addReplyBulk:
void addReplyBulk(client *c, robj *obj) {
if (tryAvoidBulkStrCopyToReply(c, obj) == C_OK) {
...
return; /* 走了 copy-avoid,不再走下面的普通路径 */
}
addReplyBulkLen(c, obj);
addReply(c, obj); /* → _addReplyToBufferOrList(会 defer) */
addReplyProto(c, "\r\n", 2);
}
Gated by isCopyAvoidPreferred; single-thread default threshold 16384:
return server.min_string_size_copy_avoid && sdslen(objectGetVal(obj)) >= (size_t)server.min_string_size_copy_avoid;
So:
- payload 16383: no CA →
addReply→_addReplyToBufferOrList→ defers → correct - payload ≥16384: CA →
_addBulkStrRefToBufferOrList→ ignores defer → bug
Looks like a “16KB chunk” issue; root cause is CA’s default threshold happens to be 16KB.
7. Both paths together: step through the bug #
Scenario: same connection HELLO 3 + SUBSCRIBE + PUBLISH 16384 bytes.
Step A: start assembling self push #
addReplyPubsubMessage sets pushing=1, then:
| Call | Which API | Result |
|---|---|---|
addReplyPushLen(>3) |
_addReplyToBufferOrList |
defer → pending |
addReply("message") |
same | defer → pending |
addReplyBulk(channel) |
small string, CA off → normal path | defer → pending |
addReplyBulk(msg) 16384 |
CA on | next step |
At this point pending_push_messages is roughly:
>3\r\n $7\r\nmessage\r\n $8\r\nwiretest\r\n
Missing the final payload.
Step B: large payload takes the wrong exit #
tryAvoidBulkStrCopyToReply → _addBulkStrRefToBufferOrList:
static void _addBulkStrRefToBufferOrList(client *c, robj *obj) {
...
/* 没有 defer_push_message 判断! */
if (!_addBulkStrRefToBuffer(c, ...)) {
_addBulkStrRefToToList(c, ...); /* 硬编码进 c->reply */
}
}
bulkStrRef lands in c->buf (or c->reply), not pending.
Memory view:
c->buf: [payloadHeader][bulkStrRef{ptr→argv[2]}]
pending: >3 ... message ... channel (缺 payload)
Step C: PUBLISH’s own reply #
addReplyLongLong(c, 1) → :1\r\n also into c->buf, right after the bulkStrRef:
c->buf: [bulkStrRef] [:1\r\n]
pending: >3 ... message ... channel
Step D: afterCommand join #
listJoin(c->reply, pending):
写出顺序:
1) 展开 bulkStrRef → $16384\r\nXXXX...\r\n
2) :1\r\n
3) >3\r\n$message\r\n$channel\r\n ← 没有 payload 的残缺 push
That is the issue’s wire capture. Client thinks $16384... is the PUBLISH reply → protocol desync.
8. One diagram to remember #
addReplyPubsubMessage (pushing=1)
│
┌───────────────────┼───────────────────┐
│ │ │
小片段 (header/ channel payload ≥16KB
message) (小 bulk) │
│ │ │
▼ ▼ ▼
_addReplyToBufferOrList addReplyBulk
│ │
│ defer? ├─ <阈值: 普通路径 → defer ✓
│ (self+pushing) └─ ≥阈值: copy-avoid
▼ │
pending_push_messages ▼
_addBulkStrRef*
→ c->buf / c->reply ✗
│
publishCommand 再写 :1 ──────────────────────────┘
│
afterCommand: listJoin(pending → reply 尾) │
▼
撕碎的 socket 字节流
9. Suggested reading order (guided tour) #
publishCommand/addReplyPubsubMessage—src/pubsub.c_addReplyToBufferOrListdefer comments —src/networking.c~744afterCommandlistJoin—src/server.c~4195addReplyBulk/isCopyAvoidPreferred/_addBulkStrRefToBufferOrList— same fileaddBulkStringToReplyIOV— how CA becomes$len\r\ndata
Local check: run an instance with the issue’s Python; or CONFIG GET min-string-size-avoid-copy-reply for the threshold. Setting the threshold to 0 disables CA and large messages work again — side evidence that the root cause is CA bypassing defer, not pubsub itself.