This is an automated email from the ASF dual-hosted git repository.
cshannon pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/activemq.git
The following commit(s) were added to refs/heads/main by this push:
new e1368d5f45 Add tests validating TextMessage body thread safety fix
(#2207)
e1368d5f45 is described below
commit e1368d5f45570f5667d73cd1c9fd7ce066fe6cff
Author: Christopher L. Shannon <[email protected]>
AuthorDate: Thu Jul 9 17:11:14 2026 -0400
Add tests validating TextMessage body thread safety fix (#2207)
Validates the fix for the race between getText(), storeContentAndClear()
and copy() on a shared ActiveMQTextMessage instance that could null the
message body: getText() decoded content into text and cleared content
while storeContentAndClear() observed the pre-clear content, skipped
re-encoding, and cleared text — losing the body permanently. Triggered on
the broker by XPath selector evaluation or JMX browse racing dispatch
marshalling, with the network bridge copy() adding a third participant.
Four tests:
- sequential control: getText/storeContentAndClear round trips preserve
the body
- two-way race: 20k iterations of concurrent getText vs
storeContentAndClear; no iteration may lose the body
- three-way race including copy(): both the original and the copy must
retain a recoverable body (pre-fix this loses the original's body
within a few thousand iterations)
- deterministic lock-discipline pin: the fix serializes the text/content
state transitions on the message instance, so storeContentAndClear()
must block while another thread holds the message monitor; fails
immediately on any regression to unsynchronized transitions
Verified failing (2 of 4) against the pre-fix base and passing on this
branch.
Co-authored-by: Matt Pavlovich <[email protected]>
---
.../ActiveMQTextMessageContentRaceTest.java | 306 +++++++++++++++++++++
1 file changed, 306 insertions(+)
diff --git
a/activemq-client/src/test/java/org/apache/activemq/command/ActiveMQTextMessageContentRaceTest.java
b/activemq-client/src/test/java/org/apache/activemq/command/ActiveMQTextMessageContentRaceTest.java
new file mode 100644
index 0000000000..698eb43c69
--- /dev/null
+++
b/activemq-client/src/test/java/org/apache/activemq/command/ActiveMQTextMessageContentRaceTest.java
@@ -0,0 +1,306 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.command;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Validates the fix for the race condition between getText(),
+ * storeContentAndClear() and copy() in ActiveMQTextMessage that could cause
+ * the message body to become permanently null.
+ *
+ * The race existed because those methods performed multi-step
+ * read-modify-clear operations on two fields (text and content) without a
+ * common monitor:
+ *
+ * Thread A (getText): Thread B (storeContentAndClear):
+ * 1. reads content -> non-null 1. storeContent(): content non-null,
+ * 2. decodes content -> text skips encoding text -> content
+ * 3. setContent(null) 2. text = null
+ *
+ * Result: content=null (cleared by A), text=null (cleared by B).
+ * The body was permanently lost.
+ *
+ * Real-world trigger: on the broker side, a transport thread calls
+ * beforeMarshall() -> storeContentAndClear() while concurrently an XPath
+ * selector evaluator (JAXPXPathEvaluator) or JMX browse calls getText() on
+ * the same Message instance, and the network bridge calls copy() on it. The
+ * broker dispatches the same Message object to multiple consumers without
+ * copying it.
+ *
+ * The fix synchronizes the state transitions on the message instance
+ * itself: getText() decodes+clears under {@code synchronized(this)} (with a
+ * double-checked volatile read of text for the fast path),
+ * storeContentAndClear() stores+clears under the same monitor, copy() takes
+ * the monitor while snapshotting both fields, and content/text are volatile.
+ */
+public class ActiveMQTextMessageContentRaceTest {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(ActiveMQTextMessageContentRaceTest.class);
+
+ private static final String TEST_TEXT = "Hello, World! This is a test
message body.";
+ private static final int ITERATIONS = 20_000;
+
+ /**
+ * Creates a message in the state it has on the broker after arriving and
+ * being trimmed by reduceMemoryFootprint: content is the marshalled
+ * bytes, text is null.
+ */
+ private ActiveMQTextMessage brokerStateMessage() throws Exception {
+ ActiveMQTextMessage msg = new ActiveMQTextMessage();
+ msg.setText(TEST_TEXT);
+ msg.storeContent();
+ msg.clearUnMarshalledState();
+ return msg;
+ }
+
+ /**
+ * Control case: sequential getText() / storeContentAndClear() round
+ * trips preserve the body.
+ */
+ @Test
+ public void testSequentialGetTextAndStoreContentPreservesBody() throws
Exception {
+ ActiveMQTextMessage msg = brokerStateMessage();
+ assertNotNull("content should be set after storeContent()",
msg.getContent());
+
+ // getText() decodes content -> text (and clears content)
+ assertEquals(TEST_TEXT, msg.getText());
+
+ // storeContentAndClear() re-encodes text -> content, clears text
+ msg.storeContentAndClear();
+ assertNotNull("content should be set after storeContentAndClear()",
msg.getContent());
+
+ // getText() again recovers the body
+ assertEquals(TEST_TEXT, msg.getText());
+ }
+
+ /**
+ * The original race: concurrent getText() and storeContentAndClear()
+ * on the same instance. Before the fix this lost the body within a few
+ * thousand iterations on any multi-core machine; with the state
+ * transitions synchronized on the message instance, no iteration may
+ * lose the body.
+ */
+ @Test(timeout = 120_000)
+ public void testConcurrentGetTextAndStoreContentAndClearPreservesBody()
throws Exception {
+ final AtomicInteger nullBodyCount = new AtomicInteger(0);
+ final AtomicReference<String> firstFailure = new AtomicReference<>();
+
+ for (int i = 0; i < ITERATIONS; i++) {
+ ActiveMQTextMessage msg = brokerStateMessage();
+
+ CyclicBarrier barrier = new CyclicBarrier(2);
+
+ // simulates XPath selector evaluation / JMX browse
+ Thread readerThread = new Thread(() -> {
+ try {
+ barrier.await();
+ msg.getText();
+ } catch (Exception e) {
+ // ignore
+ }
+ });
+
+ // simulates the transport thread marshalling for dispatch
+ Thread marshalThread = new Thread(() -> {
+ try {
+ barrier.await();
+ msg.storeContentAndClear();
+ } catch (Exception e) {
+ // ignore
+ }
+ });
+
+ readerThread.start();
+ marshalThread.start();
+ readerThread.join();
+ marshalThread.join();
+
+ String recoveredText = null;
+ try {
+ recoveredText = msg.getText();
+ } catch (Exception e) {
+ // getText might throw if content is corrupted
+ }
+
+ if (recoveredText == null) {
+ int count = nullBodyCount.incrementAndGet();
+ if (firstFailure.get() == null) {
+ firstFailure.set("Iteration " + i + ": text=" + msg.text +
+ ", content=" + msg.getContent());
+ }
+ if (count >= 10) {
+ break;
+ }
+ }
+ }
+
+ if (nullBodyCount.get() > 0) {
+ fail("RACE CONDITION: " + nullBodyCount.get() + " messages had
null body due to " +
+ "concurrent getText() and storeContentAndClear(). First
failure: " +
+ firstFailure.get());
+ }
+ LOG.info("Body preserved across {} concurrent
getText/storeContentAndClear iterations",
+ ITERATIONS);
+ }
+
+ /**
+ * Three-way race including copy(): the network bridge copies the same
+ * Message instance that local dispatch is marshalling while a selector
+ * reads it. Both the original and the copy must retain a recoverable
+ * body.
+ */
+ @Test(timeout = 120_000)
+ public void testConcurrentCopyGetTextAndStoreContentPreservesBody() throws
Exception {
+ final AtomicInteger failures = new AtomicInteger(0);
+ final AtomicReference<String> firstFailure = new AtomicReference<>();
+
+ for (int i = 0; i < ITERATIONS / 2; i++) {
+ ActiveMQTextMessage msg = brokerStateMessage();
+ AtomicReference<Message> copyRef = new AtomicReference<>();
+
+ CyclicBarrier barrier = new CyclicBarrier(3);
+
+ Thread readerThread = new Thread(() -> {
+ try {
+ barrier.await();
+ msg.getText();
+ } catch (Exception e) {
+ // ignore
+ }
+ });
+ Thread marshalThread = new Thread(() -> {
+ try {
+ barrier.await();
+ msg.storeContentAndClear();
+ } catch (Exception e) {
+ // ignore
+ }
+ });
+ Thread copyThread = new Thread(() -> {
+ try {
+ barrier.await();
+ copyRef.set(msg.copy());
+ } catch (Exception e) {
+ // ignore
+ }
+ });
+
+ readerThread.start();
+ marshalThread.start();
+ copyThread.start();
+ readerThread.join();
+ marshalThread.join();
+ copyThread.join();
+
+ String originalText = null;
+ String copyText = null;
+ try {
+ originalText = msg.getText();
+ ActiveMQTextMessage copy = (ActiveMQTextMessage) copyRef.get();
+ copyText = copy != null ? copy.getText() : "no-copy-made";
+ } catch (Exception e) {
+ // fall through with nulls
+ }
+
+ if (originalText == null || copyText == null) {
+ int count = failures.incrementAndGet();
+ if (firstFailure.get() == null) {
+ firstFailure.set("Iteration " + i + ": original=" +
originalText +
+ ", copy=" + copyText);
+ }
+ if (count >= 10) {
+ break;
+ }
+ }
+ }
+
+ if (failures.get() > 0) {
+ fail("RACE CONDITION: " + failures.get() + " iterations lost the
body on the " +
+ "original or its copy() during concurrent
copy/getText/storeContentAndClear. " +
+ "First failure: " + firstFailure.get());
+ }
+ LOG.info("Body preserved on original and copy across {} three-way
iterations",
+ ITERATIONS / 2);
+ }
+
+ /**
+ * Deterministically pins the locking discipline of the fix: the state
+ * transitions synchronize on the message instance itself. While another
+ * thread holds the message monitor, storeContentAndClear() must block —
+ * so the getText() decode+clear and the storeContentAndClear()
+ * store+clear can never interleave.
+ *
+ * If the implementation ever moves off {@code synchronized(this)} (or
+ * drops synchronization), this test fails and the concurrent tests above
+ * become the (probabilistic) safety net.
+ */
+ @Test(timeout = 60_000)
+ public void testStateTransitionsSynchronizeOnMessageInstance() throws
Exception {
+ ActiveMQTextMessage msg = brokerStateMessage();
+ // decode so text is populated and storeContentAndClear has work to do
+ assertEquals(TEST_TEXT, msg.getText());
+
+ CountDownLatch monitorHeld = new CountDownLatch(1);
+ CountDownLatch releaseMonitor = new CountDownLatch(1);
+ Thread holder = new Thread(() -> {
+ synchronized (msg) {
+ monitorHeld.countDown();
+ try {
+ releaseMonitor.await(30, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+ holder.start();
+ assertTrue("holder should acquire the message monitor",
monitorHeld.await(5, TimeUnit.SECONDS));
+
+ CountDownLatch storeDone = new CountDownLatch(1);
+ Thread storer = new Thread(() -> {
+ msg.storeContentAndClear();
+ storeDone.countDown();
+ });
+ storer.start();
+
+ assertTrue("storeContentAndClear must BLOCK while the message monitor
is held — " +
+ "the fix serializes text/content state transitions on the
message instance",
+ !storeDone.await(500, TimeUnit.MILLISECONDS));
+
+ releaseMonitor.countDown();
+ assertTrue("storeContentAndClear should complete once the monitor is
released",
+ storeDone.await(5, TimeUnit.SECONDS));
+ holder.join(5000);
+ storer.join(5000);
+
+ assertNotNull("content must be set after storeContentAndClear",
msg.getContent());
+ assertEquals("body must survive the round trip", TEST_TEXT,
msg.getText());
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]
For further information, visit: https://activemq.apache.org/contact