Skip to content

Commit f48abf8

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

2 files changed

Lines changed: 329 additions & 24 deletions

File tree

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

Lines changed: 77 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,32 @@ 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+
pendingChunkedMessageCount--;
1539+
chunkedMsgCtx.recycle();
15271540
}
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();
15321541
}
15331542

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

15741583
private ByteBuf processMessageChunk(ByteBuf compressedPayload, MessageMetadata msgMetadata, MessageIdImpl msgId,
15751584
MessageIdData messageId, ClientCnx cnx) {
1585+
// Self-guard: this method does not rely on the caller already holding chunkedMessageLock. messageReceived does
1586+
// hold it (to keep the final-chunk assembly + finalize atomic), so that path re-enters this lock harmlessly.
1587+
synchronized (chunkedMessageLock) {
1588+
return doProcessMessageChunk(compressedPayload, msgMetadata, msgId, messageId, cnx);
1589+
}
1590+
}
1591+
1592+
private ByteBuf doProcessMessageChunk(ByteBuf compressedPayload, MessageMetadata msgMetadata, MessageIdImpl msgId,
1593+
MessageIdData messageId, ClientCnx cnx) {
15761594
if (msgMetadata.getChunkId() != (msgMetadata.getNumChunksFromMsg() - 1)) {
15771595
increaseAvailablePermits(cnx);
15781596
}
@@ -1630,6 +1648,11 @@ private ByteBuf processMessageChunk(ByteBuf compressedPayload, MessageMetadata m
16301648
}
16311649
chunkedMsgCtx.recycle();
16321650
chunkedMessagesMap.remove(msgMetadata.getUuid());
1651+
// Drop the stale queue entry; it is re-added at the tail below, keeping the queue ordered by
1652+
// receivedTime with exactly one entry per in-progress uuid. The replaced ctx was already counted,
1653+
// so this '--' balances the unconditional '++' below.
1654+
pendingChunkedMessageUuidQueue.remove(msgMetadata.getUuid());
1655+
pendingChunkedMessageCount--;
16331656
}
16341657
pendingChunkedMessageCount++;
16351658
if (maxPendingChunkedMessage > 0 && pendingChunkedMessageCount > maxPendingChunkedMessage) {
@@ -1640,6 +1663,8 @@ private ByteBuf processMessageChunk(ByteBuf compressedPayload, MessageMetadata m
16401663
msgMetadata.getTotalChunkMsgSize());
16411664
chunkedMsgCtx = chunkedMessagesMap.computeIfAbsent(msgMetadata.getUuid(),
16421665
(key) -> ChunkedMessageCtx.get(totalChunks, chunkedMsgBuffer));
1666+
// Enqueue at the tail so the queue stays ordered oldest-first by receivedTime. For a redelivered first
1667+
// chunk the stale entry was removed above, so this keeps exactly one entry per in-progress uuid.
16431668
pendingChunkedMessageUuidQueue.add(msgMetadata.getUuid());
16441669
}
16451670

@@ -1686,6 +1711,11 @@ private ByteBuf processMessageChunk(ByteBuf compressedPayload, MessageMetadata m
16861711
ReferenceCountUtil.safeRelease(chunkedMsgCtx.chunkedMsgBuffer);
16871712
}
16881713
chunkedMsgCtx.recycle();
1714+
// The ctx was counted and enqueued when its first chunk created it, so keep the rest of the
1715+
// bookkeeping in sync with the map removal: drop the queue entry (no ghost) and decrement the count
1716+
// (otherwise it drifts upward and prematurely triggers removeOldestPendingChunkedMessage).
1717+
pendingChunkedMessageUuidQueue.remove(msgMetadata.getUuid());
1718+
pendingChunkedMessageCount--;
16891719
}
16901720
chunkedMessagesMap.remove(msgMetadata.getUuid());
16911721
compressedPayload.release();
@@ -3127,6 +3157,14 @@ public void recycle() {
31273157
}
31283158

31293159
private void removeOldestPendingChunkedMessage() {
3160+
// Self-guard, same as processMessageChunk: today this only runs inside doProcessMessageChunk (already under the
3161+
// lock, so this re-enters), but locking here keeps the method safe for any direct caller.
3162+
synchronized (chunkedMessageLock) {
3163+
doRemoveOldestPendingChunkedMessage();
3164+
}
3165+
}
3166+
3167+
private void doRemoveOldestPendingChunkedMessage() {
31303168
ChunkedMessageCtx chunkedMsgCtx = null;
31313169
String firstPendingMsgUuid = null;
31323170
while (chunkedMsgCtx == null && !pendingChunkedMessageUuidQueue.isEmpty()) {
@@ -3142,15 +3180,30 @@ protected void removeExpireIncompleteChunkedMessages() {
31423180
if (expireTimeOfIncompleteChunkedMessageMillis <= 0) {
31433181
return;
31443182
}
3145-
ChunkedMessageCtx chunkedMsgCtx = null;
3183+
synchronized (chunkedMessageLock) {
3184+
doRemoveExpireIncompleteChunkedMessages();
3185+
}
3186+
}
3187+
3188+
private void doRemoveExpireIncompleteChunkedMessages() {
31463189
String messageUUID;
31473190
while ((messageUUID = pendingChunkedMessageUuidQueue.peek()) != null) {
3148-
chunkedMsgCtx = StringUtils.isNotBlank(messageUUID) ? chunkedMessagesMap.get(messageUUID) : null;
3149-
if (chunkedMsgCtx != null && System
3150-
.currentTimeMillis() > (chunkedMsgCtx.receivedTime + expireTimeOfIncompleteChunkedMessageMillis)) {
3191+
ChunkedMessageCtx chunkedMsgCtx =
3192+
StringUtils.isNotBlank(messageUUID) ? chunkedMessagesMap.get(messageUUID) : null;
3193+
if (chunkedMsgCtx == null) {
3194+
// Ghost head: the chunked message already completed/was removed from chunkedMessagesMap but its uuid
3195+
// lingers in the queue. Drop it and keep scanning (mirroring doRemoveOldestPendingChunkedMessage) so
3196+
// genuinely-expired incomplete chunks queued behind it are still cleaned.
3197+
pendingChunkedMessageUuidQueue.remove(messageUUID);
3198+
continue;
3199+
}
3200+
if (System.currentTimeMillis()
3201+
> (chunkedMsgCtx.receivedTime + expireTimeOfIncompleteChunkedMessageMillis)) {
31513202
pendingChunkedMessageUuidQueue.remove(messageUUID);
31523203
removeChunkMessage(messageUUID, chunkedMsgCtx, true);
31533204
} else {
3205+
// The queue is ordered oldest-first, so the first live not-yet-expired head means nothing behind it
3206+
// is expired either.
31543207
return;
31553208
}
31563209
}

0 commit comments

Comments
 (0)