[fix][client] Serialize chunked-message bookkeeping to fix use-after-…#26084
[fix][client] Serialize chunked-message bookkeeping to fix use-after-…#26084SongOf wants to merge 1 commit into
Conversation
| * that writes into the same {@code chunkedMsgBuffer} — exactly when the expiry task may release/recycle that ctx. | ||
| * Without serialization this races (use-after-free / double-recycle). With the fix, both paths take | ||
| * chunkedMessageLock, so it passes and pendingChunkedMessageCount stays equal to chunkedMessagesMap.size(). | ||
| * See .claude/pulsar-client-review-findings.md (#1). |
| } | ||
|
|
||
| /** | ||
| * Deterministic reproducer for finding #2: a duplicated first chunk (chunkId == 0, redelivered) for a uuid that |
There was a problem hiding this comment.
referring to findings doesn't make sense in code comments
There was a problem hiding this comment.
removed, thanks for your comments.
69fb6f6 to
910db42
Compare
| .log("Chunked message completed chunkId, total-chunks, msgId sequenceId"); | ||
|
|
||
| // remove buffer from the map, set the chunk message id | ||
| ChunkedMessageCtx chunkedMsgCtx = chunkedMessagesMap.remove(msgMetadata.getUuid()); |
There was a problem hiding this comment.
I think this still leaves a stale-queue-head case. The PR now states that pendingChunkedMessageUuidQueue has exactly one entry per in-progress uuid, but not every map removal keeps that invariant. The final-chunk path removes the ctx from chunkedMessagesMap and decrements the count without removing the uuid from pendingChunkedMessageUuidQueue; the unexpected/out-of-order branch also removes/recycles the ctx without removing the queue entry or decrementing pendingChunkedMessageCount.
That matters because doRemoveExpireIncompleteChunkedMessages() uses peek(). If the queue head points to a completed/removed uuid, chunkedMsgCtx is null and the method returns, so later expired incomplete chunked messages behind that stale head are never expired. I reproduced this on the PR head by sending chunk 0 then out-of-order chunk 2 for one uuid, then an expired chunk 0 for another uuid; removeExpireIncompleteChunkedMessages() returns at the stale head and leaves the second ctx in the map.
Could we either remove the uuid from pendingChunkedMessageUuidQueue on every path that removes from chunkedMessagesMap (final completion and unexpected/out-of-order cleanup included), or make the expiry loop drain stale null heads before deciding that the oldest live ctx is not expired?
There was a problem hiding this comment.
fixed in the final-chunk path. and fixed in out-of-order chunk path. At the same time, modify doRemoveExpireIncompleteChunkedMessages to handle null elements at the head of the queue, aligning its logic with the doRemoveOldestPendingChunkedMessage method.
| } | ||
| // Keep assembly of the final chunk and the finalize (map removal + recycle) in one critical section so | ||
| // the expiry/eviction path can't release/recycle this ctx in between. | ||
| synchronized (chunkedMessageLock) { |
There was a problem hiding this comment.
The lock is described as guarding only chunk bookkeeping, but this path currently holds chunkedMessageLock through final-chunk decompression in doProcessMessageChunk() (uncompressPayloadIfNeeded). That can be a relatively expensive codec operation and, on decompression failure, can call discardCorruptedMessage() while the lock is held. This serializes unrelated chunked-message completions and expiry behind decompression work.
Could we narrow the critical section to only append/remove the ctx and capture the assembled buffer / chunk ids, then decompress and build the final message outside the lock? That would still close the assemble -> finalize race without turning the lock into a codec/decompression gate.
f48abf8 to
d9b2d45
Compare
…free and count/queue drift
d9b2d45 to
33da7ad
Compare
Motivation
ConsumerImpl's chunked-message reassembly state — the per-uuidChunkedMessageCtx,its
chunkedMsgBuffer,pendingChunkedMessageCountandpendingChunkedMessageUuidQueue— is mutated from two different threads with no synchronization:
processMessageChunk, and the last-chunk finalize inmessageReceived) runs on the Netty IO event-loop thread(
ClientCnx.handleMessagecallsconsumer.messageReceived(...)directly);removeExpireIncompleteChunkedMessages) runs onthe client's
internalPinnedExecutor— a separate single-thread pool(
client.getInternalExecutorService()), not theeventLoopGroup.When a late chunk for a uuid arrives while the expiry task is removing that same ctx,
the expiry thread can
release()/recycle()aChunkedMessageCtxand its buffer whilethe receive thread is still writing into it. This races into:
chunkedMsgBuffer(IllegalReferenceCountException,or worse — writing into memory the allocator already handed to someone else);
ChunkedMessageCtx.recycle(), which corrupts the NettyRecyclerpool and canhand the same instance to two different chunked messages;
pendingChunkedMessageCountdrift (non-atomicintmutated from both threads).Incomplete-chunk expiry is enabled by default (
expireTimeOfIncompleteChunkedMessageMillis= 1 minute), so any consumer of chunked messages is exposed.
Separately, a redelivered first chunk (
chunkId == 0for a uuid that already has anin-progress ctx) replaced the old ctx without decrementing
pendingChunkedMessageCountand re-enqueued the uuid, so the counter over-counted and
pendingChunkedMessageUuidQueueended up with duplicate / mis-ordered entries (the queue is meant to be ordered oldest-first
by
receivedTime, with one entry per in-progress uuid, sinceremoveExpire/removeOldestclean from the head).
Modifications
chunkedMessageLock.processMessageChunk,removeOldestPendingChunkedMessageandremoveExpireIncompleteChunkedMessagesnow acquireit (thin wrapper over an extracted body), and
messageReceivedholds it across thelast-chunk assembly + finalize so the assemble→finalize window is closed. All chunk
bookkeeping is serialized, and
pendingChunkedMessageCountis mutated only under the lock(so it needs no
volatile/Atomic). Heavy per-message work (newMessage, decryption ofnon-chunk payloads,
executeNotifyCallback) stays outside the lock, and non-chunkedmessages never touch it.
(
ConcurrentHashMapops,doAcknowledgewhich is async/non-blocking) never acquire itin reverse, so there is no new lock-ordering / deadlock and no blocking call held across
the lock.
pendingChunkedMessageCountfor thereplaced ctx,
remove(uuid)its stale entry frompendingChunkedMessageUuidQueueandre-
add(uuid)at the tail — keeping the queue ordered oldest-first with exactly one entryper in-progress uuid (net 0 count change on replace, +1 on a genuinely new uuid).
This is an internal client-side locking change only; no public API, wire protocol, schema,
config defaults or metrics are changed.
Verifying this change
This change added tests and can be verified as follows:
ConsumerImplTest.testChunkedMessageCountRaceBetweenReceiveAndExpiry— drivesprocessMessageChunkon a "receiver" thread concurrently with the realremoveExpireIncompleteChunkedMessageson an "expirer" thread, for the "a late chunkarrives for a uuid that is concurrently being expired" scenario. Verified to fail before
the fix (
IllegalReferenceCountException/ corruptedpendingChunkedMessageCount) andpass after.
ConsumerImplTest.testDuplicateFirstChunkOvercountsPendingChunkedMessageCount—deterministic; delivers a redelivered first chunk and asserts
pendingChunkedMessageCount == chunkedMessagesMap.size()and that the uuid appears exactlyonce in
pendingChunkedMessageUuidQueue. Verified to fail before and pass after.Full
pulsar-clientunit suite (712 tests) passes with no regressions.Does this pull request potentially affect one of the following parts:
Documentation
doc-requireddoc-not-needed(internal bug fix; no user-facing behavior or config change)
docdoc-complete