[fix][client] Serialize chunked-message bookkeeping to fix use-after-free and count/queue drift - #26084
[fix][client] Serialize chunked-message bookkeeping to fix use-after-free and count/queue drift#26084SongOf wants to merge 2 commits into
Conversation
69fb6f6 to
910db42
Compare
d9b2d45 to
33da7ad
Compare
lhotari
left a comment
There was a problem hiding this comment.
Review performed with AI assistance (Claude Code / Claude Fable 5 combined with a Codex gpt-5.6-sol review pass); findings below were verified against the code before posting.
Overall the change looks sound and worthwhile. The receive/assembly path (Netty IO thread) and the incomplete-chunk expiry path (internalPinnedExecutor) genuinely race on ChunkedMessageCtx, its buffer and pendingChunkedMessageCount, and the new chunkedMessageLock serializes them correctly. The lock scope is well chosen — decompression, newMessage and callback dispatch stay outside it, non-chunked messages never touch it — and I verified there are no lock-ordering cycles (nothing called under the lock — doAcknowledge, increaseAvailablePermits, trackMessage — re-enters chunk code) and that buffer refcounts stay balanced (uncompressPayloadIfNeeded does not consume its input). The PR also fixes several real pre-existing bugs beyond the headline race: the assemble→finalize NPE window, the duplicate-first-chunk overcount, the forward-gap discard desync, and — most impactful — the ghost-head bug where a completed uuid at the queue head permanently stalled expiry. All four new tests pass locally (:pulsar-client-original:test, race test ~4s).
One regression should be addressed before merge:
1. Decompression failure now permanently drops the earlier chunks' message IDs (final-chunk path in messageReceived)
The ctx is removed from chunkedMessagesMap and recycled inside the lock before decompression. If uncompressPayloadIfNeeded then fails, the code returns without acking or tracking the captured chunkedMessageIds; discardCorruptedMessage inside it only handles the final chunk's ID. Since the ctx is gone, the expiry sweep can never reach those IDs either — the first n−1 chunks stay unacked until the consumer reconnects (permanent ack hole / stuck backlog on that subscription). Previously the ctx stayed in the map and expiry eventually acked all recorded chunk IDs (before tripping the latent double-release this PR fixes — broken differently, but disposal did happen). Suggested fix: in the uncompressedPayload == null branch, individually doAcknowledge each non-null captured ID, mirroring the corrupted-chunk handling in doProcessMessageChunk / removeChunkMessage(..., autoAck=true) semantics.
Test / hygiene items:
2. Reflection into private state in the new tests. Project convention is no reflection into private state — use @VisibleForTesting package-private accessors instead. ConsumerImplTest is already in org.apache.pulsar.client.impl, so making processMessageChunk, pendingChunkedMessageCount, pendingChunkedMessageUuidQueue and expireChunkMessageTaskScheduled package-private removes all setAccessible/FieldUtils use. Some reflection is unnecessary even now: expireTimeOfIncompleteChunkedMessageMillis is protected and can be assigned directly, as the test already does with chunkedMessagesMap.
3. Stale javadoc on testDuplicateFirstChunkOvercountsPendingChunkedMessageCount: it still says "Currently FAILS deterministically … Enable once the duplicate-first-chunk path decrements the counter", which is pre-fix wording — the fix is included in this PR and the test passes. It also hardcodes source line references ("ConsumerImpl.java:1628-1634") that will rot. Please rewrite it to describe the guarded invariant.
4. The race test has no timeout. testChunkedMessageCountRaceBetweenReceiveAndExpiry uses unbounded receiver.join()/expirer.join() and no @Test(timeOut = …). The scenario it guards is exactly the kind whose future regression could be a deadlock between the two paths — which would hang the suite instead of failing. Please add a generous timeOut.
5. The PR description understates the change (in a good way): the ghost-head expiry-stall fix in doRemoveExpireIncompleteChunkedMessages (previously a completed uuid at the queue head blocked all expiry behind it indefinitely) and the forward-gap discard count/queue sync fix aren't mentioned, and "Verifying this change" lists 2 tests while 4 were added. Please update Motivation/Modifications so the full behavior change is captured for reviewers and release notes.
6. The PR title is truncated — it literally ends with a Unicode ellipsis ("…to fix use-after-…", GitHub's auto-fill from a long commit subject). Please complete it, e.g. [fix][client] Serialize chunked-message bookkeeping to fix use-after-free and count drift.
…free and count/queue drift
# Conflicts: # pulsar-client/src/test/java/org/apache/pulsar/client/impl/ConsumerImplTest.java
d46ad7b to
3e4b95b
Compare
|
@lhotari Thanks for the thorough review — fixed 1–4:
|
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