@@ -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