This is an automated email from the ASF dual-hosted git repository.
apupier pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 1e03a4f33089 Fix NPE in BacklogTracer when transform is the last route
step
1e03a4f33089 is described below
commit 1e03a4f330897db0fcd6c58059377702a05fddbf
Author: Thomas Raddatz <[email protected]>
AuthorDate: Fri Sep 18 07:58:29 2026 +0200
Fix NPE in BacklogTracer when transform is the last route step
ExchangeHelper.replaceMessage() fetches old via exchange.getMessage(),
which returns the current IN message when there is no OUT message yet. When
TransformProcessor replaces a specialized (non-DefaultMessage) IN message with
outOnly=true, old ends up being the same instance as exchange.getIn(). The
method then sets the new message as OUT, leaving IN untouched, but
unconditionally detaches old's exchange reference via
messageSupport.setExchange(null), so the IN message stays attached [...]
Normally the next pipeline step calls ExchangeHelper.prepareOutToIn,
promoting OUT to IN and reattaching it, which hides the dangling reference.
When transform is the last step of a route, this never happens, and
BacklogTracerRouteAdvice#after crashes with a NullPointerException while
dumping exchange.getIn() for tracing (only reached when the backlog tracer is
enabled, or in standby mode with message history enabled, as camel-debug sets
up in Quarkus dev mode).
Only detach old from the exchange in replaceMessage() when it is no longer
referenced as IN or OUT afterwards.
Add regression tests in ExchangeHelperTest covering replaceMessage() with
and without an existing OUT message, and a new
TransformLastStepBacklogTracerNpeTest exercising the full route scenario with a
DefaultMessage subclass (as platform-http and http create) and transform as the
last step.
Co-authored-by: Claude <[email protected]>
---
.../TransformLastStepBacklogTracerNpeTest.java | 99 ++++++++++++++++++++++
.../org/apache/camel/util/ExchangeHelperTest.java | 50 +++++++++++
.../org/apache/camel/support/ExchangeHelper.java | 7 +-
3 files changed, 154 insertions(+), 2 deletions(-)
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/TransformLastStepBacklogTracerNpeTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/TransformLastStepBacklogTracerNpeTest.java
new file mode 100644
index 000000000000..fe2a2a814c1f
--- /dev/null
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/TransformLastStepBacklogTracerNpeTest.java
@@ -0,0 +1,99 @@
+/*
+ * 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.camel.processor;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.ContextTestSupport;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.DefaultMessage;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A specialized (subclass of {@link DefaultMessage}) IN message, such as the
one platform-http or http create, must
+ * stay attached to the exchange when a {@code transform} EIP is the last step
of a route and the backlog tracer is
+ * active (or in standby mode with message history enabled, as camel-debug
sets it up). Otherwise
+ * BacklogTracerRouteAdvice#after fails with a NullPointerException while
dumping the IN message.
+ */
+class TransformLastStepBacklogTracerNpeTest extends ContextTestSupport {
+
+ @Test
+ void transformAsLastStepWithSpecializedMessage() {
+ Exchange out = sendSpecializedMessage("direct:transformLast", "Hello");
+
+ assertThat(out.getException()).as("route must not fail").isNull();
+ assertThat(out.getMessage().getBody(String.class)).isEqualTo("Hello
World");
+ }
+
+ @Test
+ void transformAsLastStepWithPlainDefaultMessage() {
+ Exchange exchange = new DefaultExchange(context);
+ exchange.getIn().setBody("Hello");
+
+ Exchange out = template.send("direct:transformLast", exchange);
+
+ assertThat(out.getException()).as("route must not fail").isNull();
+ assertThat(out.getMessage().getBody(String.class)).isEqualTo("Hello
World");
+ }
+
+ @Test
+ void transformFollowedByAnotherStepWithSpecializedMessage() {
+ Exchange out = sendSpecializedMessage("direct:transformFollowed",
"Hello");
+
+ assertThat(out.getException()).as("route must not fail").isNull();
+ assertThat(out.getMessage().getBody(String.class)).isEqualTo("Hello
World");
+ }
+
+ private Exchange sendSpecializedMessage(String uri, String body) {
+ Exchange exchange = new DefaultExchange(context);
+ exchange.setIn(new SpecializedMessage(context));
+ exchange.getIn().setBody(body);
+ return template.send(uri, exchange);
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ context.setUseBreadcrumb(false);
+ context.setBacklogTracingStandby(true);
+ context.setMessageHistory(true);
+
+ from("direct:transformLast")
+ .transform(simple("${body} World"));
+
+ from("direct:transformFollowed")
+ .transform(simple("${body} World"))
+ .removeHeader("X-Does-Not-Exist");
+ }
+ };
+ }
+
+ /**
+ * Mimics HttpMessage (platform-http, http, etc.): a specialized message
that is not exactly a
+ * {@link DefaultMessage}, forcing TransformProcessor to copy it into a
new DefaultMessage.
+ */
+ private static class SpecializedMessage extends DefaultMessage {
+ SpecializedMessage(CamelContext camelContext) {
+ super(camelContext);
+ }
+ }
+}
diff --git
a/core/camel-core/src/test/java/org/apache/camel/util/ExchangeHelperTest.java
b/core/camel-core/src/test/java/org/apache/camel/util/ExchangeHelperTest.java
index 564049ac9249..c2c03180efd1 100644
---
a/core/camel-core/src/test/java/org/apache/camel/util/ExchangeHelperTest.java
+++
b/core/camel-core/src/test/java/org/apache/camel/util/ExchangeHelperTest.java
@@ -23,12 +23,14 @@ import java.util.Map;
import org.apache.camel.ContextTestSupport;
import org.apache.camel.Exchange;
import org.apache.camel.ExchangePattern;
+import org.apache.camel.Message;
import org.apache.camel.NoSuchBeanException;
import org.apache.camel.NoSuchHeaderException;
import org.apache.camel.NoSuchPropertyException;
import org.apache.camel.converter.stream.InputStreamCache;
import org.apache.camel.impl.DefaultCamelContext;
import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.DefaultMessage;
import org.apache.camel.support.ExchangeHelper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -249,6 +251,54 @@ public class ExchangeHelperTest extends ContextTestSupport
{
assertNull(third);
}
+ @Test
+ public void testReplaceMessageOutOnlyWithNoExistingOutKeepsInAttached() {
+ // simulates what TransformProcessor does when transform is the last
step of a route:
+ // replace the message as OUT, even though there is no OUT message
yet, so the current
+ // IN message is what exchange.getMessage() (aliased here as "old")
actually returns
+ assertFalse(exchange.hasOut());
+ Message in = exchange.getIn();
+
+ Message replacement = new DefaultMessage(exchange.getContext());
+ ExchangeHelper.replaceMessage(exchange, replacement, true);
+
+ assertSame(replacement, exchange.getOut());
+ assertSame(in, exchange.getIn());
+ // the IN message must remain attached to the exchange, otherwise
anything that calls
+ // exchange.getIn().getExchange() afterwards (e.g. the backlog tracer)
hits an NPE
+ assertNotNull(exchange.getIn().getExchange());
+ }
+
+ @Test
+ public void testReplaceMessageOutOnlyWithExistingOutDetachesOldOut() {
+ Message firstOut = new DefaultMessage(exchange.getContext());
+ exchange.setOut(firstOut);
+
+ Message replacement = new DefaultMessage(exchange.getContext());
+ ExchangeHelper.replaceMessage(exchange, replacement, true);
+
+ assertSame(replacement, exchange.getOut());
+ assertNull(firstOut.getExchange());
+ }
+
+ @Test
+ public void testReplaceMessageInOnlyWithNoExistingOutDoesNotCreateOut() {
+ // simulates what SetBodyProcessor and ConvertBodyProcessor do:
replace the IN message in place
+ assertFalse(exchange.hasOut());
+ Message in = exchange.getIn();
+
+ Message replacement = new DefaultMessage(exchange.getContext());
+ replacement.setBody("new");
+ ExchangeHelper.replaceMessage(exchange, replacement, false);
+
+ assertSame(replacement, exchange.getIn());
+ // must not lazily create an OUT message, otherwise
exchange.getMessage() would return an empty OUT
+ assertFalse(exchange.hasOut());
+ assertEquals("new", exchange.getMessage().getBody(String.class));
+ // the old IN message is no longer referenced by the exchange and must
be detached
+ assertNull(in.getExchange());
+ }
+
@Override
@BeforeEach
public void setUp() throws Exception {
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/ExchangeHelper.java
b/core/camel-support/src/main/java/org/apache/camel/support/ExchangeHelper.java
index 1f4e32d81067..64f83528f874 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/ExchangeHelper.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/ExchangeHelper.java
@@ -878,8 +878,11 @@ public final class ExchangeHelper {
exchange.setIn(newMessage);
}
- // need to de-reference old from the exchange so it can be GC
- if (old instanceof MessageSupport messageSupport) {
+ // need to de-reference old from the exchange so it can be GC, but
only if the exchange no longer
+ // references it: with outOnly and no OUT message yet, old is the
(untouched) IN message and detaching it
+ // would leave IN without an exchange reference. Use hasOut() before
getOut() as getOut() lazily creates OUT.
+ if (old != exchange.getIn() && !(exchange.hasOut() && old ==
exchange.getOut())
+ && old instanceof MessageSupport messageSupport) {
messageSupport.setExchange(null);
}
}