Skip to content

Commit d9b2d45

Browse files
author
maxlisongsong
committed
[fix][client] Serialize chunked-message bookkeeping to fix use-after-free and count/queue drift
1 parent b70b3a3 commit d9b2d45

2 files changed

Lines changed: 332 additions & 24 deletions

File tree

pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@ public class ConsumerImpl<T> extends ConsumerBase<T> implements ConnectionHandle
218218

219219
protected Map<String, ChunkedMessageCtx> chunkedMessagesMap = new ConcurrentHashMap<>();
220220
private int pendingChunkedMessageCount = 0;
221+
// Serializes all chunked-message bookkeeping. The receive/assembly path (processMessageChunk and
222+
// removeOldestPendingChunkedMessage, on the Netty IO thread) and the eviction/expiry path
223+
// (removeExpireIncompleteChunkedMessages, on the internalPinnedExecutor) both mutate the same ChunkedMessageCtx,
224+
// its buffer and pendingChunkedMessageCount; without this lock they can race (use-after-free / double-recycle).
225+
private final Object chunkedMessageLock = new Object();
221226
protected long expireTimeOfIncompleteChunkedMessageMillis = 0;
222227
private final AtomicBoolean expireChunkMessageTaskScheduled = new AtomicBoolean(false);
223228
private final int maxPendingChunkedMessage;
@@ -1507,28 +1512,35 @@ void messageReceived(CommandMessage cmdMessage, ByteBuf headersAndPayload, Clien
15071512
if (isMessageUndecryptable || (numMessages == 1 && !msgMetadata.hasNumMessagesInBatch())) {
15081513

15091514
if (isChunkedMessage) {
1510-
uncompressedPayload = processMessageChunk(uncompressedPayload, msgMetadata, msgId, messageId, cnx);
1511-
if (uncompressedPayload == null) {
1512-
return;
1513-
}
1515+
// Keep assembly of the final chunk and the finalize (map removal + recycle) in one critical section so
1516+
// the expiry/eviction path can't release/recycle this ctx in between.
1517+
synchronized (chunkedMessageLock) {
1518+
uncompressedPayload = processMessageChunk(uncompressedPayload, msgMetadata, msgId, messageId, cnx);
1519+
if (uncompressedPayload == null) {
1520+
return;
1521+
}
15141522

1515-
// last chunk received: so, stitch chunked-messages and clear up chunkedMsgBuffer
1516-
log.debug().attr("chunkid", msgMetadata.getChunkId())
1517-
.attr("totalChunks", msgMetadata.getNumChunksFromMsg())
1518-
.attr("msgid", msgId)
1519-
.attr("sequenceid", msgMetadata.getSequenceId())
1520-
.log("Chunked message completed chunkId, total-chunks, msgId sequenceId");
1521-
1522-
// remove buffer from the map, set the chunk message id
1523-
ChunkedMessageCtx chunkedMsgCtx = chunkedMessagesMap.remove(msgMetadata.getUuid());
1524-
if (chunkedMsgCtx.chunkedMessageIds.length > 0) {
1525-
msgId = new ChunkMessageIdImpl(chunkedMsgCtx.chunkedMessageIds[0],
1526-
chunkedMsgCtx.chunkedMessageIds[chunkedMsgCtx.chunkedMessageIds.length - 1]);
1523+
// last chunk received: so, stitch chunked-messages and clear up chunkedMsgBuffer
1524+
log.debug().attr("chunkid", msgMetadata.getChunkId())
1525+
.attr("totalChunks", msgMetadata.getNumChunksFromMsg())
1526+
.attr("msgid", msgId)
1527+
.attr("sequenceid", msgMetadata.getSequenceId())
1528+
.log("Chunked message completed chunkId, total-chunks, msgId sequenceId");
1529+
1530+
// remove buffer from the map, set the chunk message id
1531+
ChunkedMessageCtx chunkedMsgCtx = chunkedMessagesMap.remove(msgMetadata.getUuid());
1532+
if (chunkedMsgCtx.chunkedMessageIds.length > 0) {
1533+
msgId = new ChunkMessageIdImpl(chunkedMsgCtx.chunkedMessageIds[0],
1534+
chunkedMsgCtx.chunkedMessageIds[chunkedMsgCtx.chunkedMessageIds.length - 1]);
1535+
}
1536+
// add chunked messageId to unack-message tracker, and reduce pending-chunked-message count
1537+
unAckedChunkedMessageIdSequenceMap.put(msgId, chunkedMsgCtx.chunkedMessageIds);
1538+
// Drop the completed uuid from the queue too, mirroring the map removal, so it doesn't linger as a
1539+
// ghost entry.
1540+
pendingChunkedMessageUuidQueue.remove(msgMetadata.getUuid());
1541+
pendingChunkedMessageCount--;
1542+
chunkedMsgCtx.recycle();
15271543
}
1528-
// add chunked messageId to unack-message tracker, and reduce pending-chunked-message count
1529-
unAckedChunkedMessageIdSequenceMap.put(msgId, chunkedMsgCtx.chunkedMessageIds);
1530-
pendingChunkedMessageCount--;
1531-
chunkedMsgCtx.recycle();
15321544
}
15331545

15341546
// If the topic is non-persistent, we should not ignore any messages.
@@ -1573,6 +1585,15 @@ void messageReceived(CommandMessage cmdMessage, ByteBuf headersAndPayload, Clien
15731585

15741586
private ByteBuf processMessageChunk(ByteBuf compressedPayload, MessageMetadata msgMetadata, MessageIdImpl msgId,
15751587
MessageIdData messageId, ClientCnx cnx) {
1588+
// Self-guard: this method does not rely on the caller already holding chunkedMessageLock. messageReceived does
1589+
// hold it (to keep the final-chunk assembly + finalize atomic), so that path re-enters this lock harmlessly.
1590+
synchronized (chunkedMessageLock) {
1591+
return doProcessMessageChunk(compressedPayload, msgMetadata, msgId, messageId, cnx);
1592+
}
1593+
}
1594+
1595+
private ByteBuf doProcessMessageChunk(ByteBuf compressedPayload, MessageMetadata msgMetadata, MessageIdImpl msgId,
1596+
MessageIdData messageId, ClientCnx cnx) {
15761597
if (msgMetadata.getChunkId() != (msgMetadata.getNumChunksFromMsg() - 1)) {
15771598
increaseAvailablePermits(cnx);
15781599
}
@@ -1630,6 +1651,11 @@ private ByteBuf processMessageChunk(ByteBuf compressedPayload, MessageMetadata m
16301651
}
16311652
chunkedMsgCtx.recycle();
16321653
chunkedMessagesMap.remove(msgMetadata.getUuid());
1654+
// Drop the stale queue entry; it is re-added at the tail below, keeping the queue ordered by
1655+
// receivedTime with exactly one entry per in-progress uuid. The replaced ctx was already counted,
1656+
// so this '--' balances the unconditional '++' below.
1657+
pendingChunkedMessageUuidQueue.remove(msgMetadata.getUuid());
1658+
pendingChunkedMessageCount--;
16331659
}
16341660
pendingChunkedMessageCount++;
16351661
if (maxPendingChunkedMessage > 0 && pendingChunkedMessageCount > maxPendingChunkedMessage) {
@@ -1640,6 +1666,8 @@ private ByteBuf processMessageChunk(ByteBuf compressedPayload, MessageMetadata m
16401666
msgMetadata.getTotalChunkMsgSize());
16411667
chunkedMsgCtx = chunkedMessagesMap.computeIfAbsent(msgMetadata.getUuid(),
16421668
(key) -> ChunkedMessageCtx.get(totalChunks, chunkedMsgBuffer));
1669+
// Enqueue at the tail so the queue stays ordered oldest-first by receivedTime. For a redelivered first
1670+
// chunk the stale entry was removed above, so this keeps exactly one entry per in-progress uuid.
16431671
pendingChunkedMessageUuidQueue.add(msgMetadata.getUuid());
16441672
}
16451673

@@ -1686,6 +1714,11 @@ private ByteBuf processMessageChunk(ByteBuf compressedPayload, MessageMetadata m
16861714
ReferenceCountUtil.safeRelease(chunkedMsgCtx.chunkedMsgBuffer);
16871715
}
16881716
chunkedMsgCtx.recycle();
1717+
// The ctx was counted and enqueued when its first chunk created it, so keep the rest of the
1718+
// bookkeeping in sync with the map removal: drop the queue entry (no ghost) and decrement the count
1719+
// (otherwise it drifts upward and prematurely triggers removeOldestPendingChunkedMessage).
1720+
pendingChunkedMessageUuidQueue.remove(msgMetadata.getUuid());
1721+
pendingChunkedMessageCount--;
16891722
}
16901723
chunkedMessagesMap.remove(msgMetadata.getUuid());
16911724
compressedPayload.release();
@@ -3127,6 +3160,14 @@ public void recycle() {
31273160
}
31283161

31293162
private void removeOldestPendingChunkedMessage() {
3163+
// Self-guard, same as processMessageChunk: today this only runs inside doProcessMessageChunk (already under the
3164+
// lock, so this re-enters), but locking here keeps the method safe for any direct caller.
3165+
synchronized (chunkedMessageLock) {
3166+
doRemoveOldestPendingChunkedMessage();
3167+
}
3168+
}
3169+
3170+
private void doRemoveOldestPendingChunkedMessage() {
31303171
ChunkedMessageCtx chunkedMsgCtx = null;
31313172
String firstPendingMsgUuid = null;
31323173
while (chunkedMsgCtx == null && !pendingChunkedMessageUuidQueue.isEmpty()) {
@@ -3142,15 +3183,30 @@ protected void removeExpireIncompleteChunkedMessages() {
31423183
if (expireTimeOfIncompleteChunkedMessageMillis <= 0) {
31433184
return;
31443185
}
3145-
ChunkedMessageCtx chunkedMsgCtx = null;
3186+
synchronized (chunkedMessageLock) {
3187+
doRemoveExpireIncompleteChunkedMessages();
3188+
}
3189+
}
3190+
3191+
private void doRemoveExpireIncompleteChunkedMessages() {
31463192
String messageUUID;
31473193
while ((messageUUID = pendingChunkedMessageUuidQueue.peek()) != null) {
3148-
chunkedMsgCtx = StringUtils.isNotBlank(messageUUID) ? chunkedMessagesMap.get(messageUUID) : null;
3149-
if (chunkedMsgCtx != null && System
3150-
.currentTimeMillis() > (chunkedMsgCtx.receivedTime + expireTimeOfIncompleteChunkedMessageMillis)) {
3194+
ChunkedMessageCtx chunkedMsgCtx =
3195+
StringUtils.isNotBlank(messageUUID) ? chunkedMessagesMap.get(messageUUID) : null;
3196+
if (chunkedMsgCtx == null) {
3197+
// Ghost head: the chunked message already completed/was removed from chunkedMessagesMap but its uuid
3198+
// lingers in the queue. Drop it and keep scanning (mirroring doRemoveOldestPendingChunkedMessage) so
3199+
// genuinely-expired incomplete chunks queued behind it are still cleaned.
3200+
pendingChunkedMessageUuidQueue.remove(messageUUID);
3201+
continue;
3202+
}
3203+
if (System.currentTimeMillis()
3204+
> (chunkedMsgCtx.receivedTime + expireTimeOfIncompleteChunkedMessageMillis)) {
31513205
pendingChunkedMessageUuidQueue.remove(messageUUID);
31523206
removeChunkMessage(messageUUID, chunkedMsgCtx, true);
31533207
} else {
3208+
// The queue is ordered oldest-first, so the first live not-yet-expired head means nothing behind it
3209+
// is expired either.
31543210
return;
31553211
}
31563212
}

0 commit comments

Comments
 (0)