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);
         }
     }

Reply via email to