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