This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/camel-4.22.x by this push:
     new 6b3ba507e012 [backport 4.22.x] CAMEL-24668: Fix MLLP consumer 
discarding pipelined HL7 messages (#26261)
6b3ba507e012 is described below

commit 6b3ba507e012eb04e636756fef51810fcf4a4f98
Author: JinyuChen97 <[email protected]>
AuthorDate: Thu Sep 10 15:21:10 2026 +0100

    [backport 4.22.x] CAMEL-24668: Fix MLLP consumer discarding pipelined HL7 
messages (#26261)
    
    Pipelined MLLP messages (multiple HL7 messages sent in a single TCP write)
    were silently dropped because the buffer only tracked the first envelope.
    Extract a processBufferedMessages() loop that preserves trailing data across
    reset/ACK cycles, and add isCompleteEnvelopeAt() to distinguish real 
pipelined
    messages from junk containing SOB bytes. Also handles senders that terminate
    frames with <FS><CR><LF>, where the trailing <LF> previously discarded the
    next pipelined message when it spanned multiple reads.
    
    Co-authored-by: Federico Mariani <[email protected]>
    Co-authored-by: Claude Opus 4.6 <[email protected]>
    
    Closes #26261
---
 .../component/mllp/internal/MllpSocketBuffer.java  |  27 +++++
 .../mllp/internal/TcpSocketConsumerRunnable.java   | 108 +++++++++++++----
 ...MllpTcpServerConsumerPipelinedMessagesTest.java | 133 +++++++++++++++++++++
 .../test/junit/rule/mllp/MllpClientResource.java   |  54 +++++++++
 4 files changed, 300 insertions(+), 22 deletions(-)

diff --git 
a/components/camel-mllp/src/main/java/org/apache/camel/component/mllp/internal/MllpSocketBuffer.java
 
b/components/camel-mllp/src/main/java/org/apache/camel/component/mllp/internal/MllpSocketBuffer.java
index ca8bb67a2589..eb0a8bcb1e43 100644
--- 
a/components/camel-mllp/src/main/java/org/apache/camel/component/mllp/internal/MllpSocketBuffer.java
+++ 
b/components/camel-mllp/src/main/java/org/apache/camel/component/mllp/internal/MllpSocketBuffer.java
@@ -553,6 +553,33 @@ public class MllpSocketBuffer {
         }
     }
 
+    /**
+     * Determine whether a complete MLLP envelope begins at the supplied 
buffer position.
+     *
+     * @param  startIndex the position expected to contain {@link 
MllpProtocolConstants#START_OF_BLOCK}
+     * @return            {@code true} if a complete envelope begins at {@code 
startIndex}
+     */
+    public boolean isCompleteEnvelopeAt(int startIndex) {
+        lock.lock();
+        try {
+            if (startIndex < 0 || startIndex >= availableByteCount
+                    || buffer[startIndex] != 
MllpProtocolConstants.START_OF_BLOCK) {
+                return false;
+            }
+
+            for (int i = startIndex + 1; i < availableByteCount; i++) {
+                if (buffer[i] == MllpProtocolConstants.END_OF_BLOCK) {
+                    return !isEndOfDataRequired()
+                            || i + 1 < availableByteCount && buffer[i + 1] == 
MllpProtocolConstants.END_OF_DATA;
+                }
+            }
+
+            return false;
+        } finally {
+            lock.unlock();
+        }
+    }
+
     public boolean hasStartOfBlock() {
         lock.lock();
         try {
diff --git 
a/components/camel-mllp/src/main/java/org/apache/camel/component/mllp/internal/TcpSocketConsumerRunnable.java
 
b/components/camel-mllp/src/main/java/org/apache/camel/component/mllp/internal/TcpSocketConsumerRunnable.java
index 794371fab455..db05e0120398 100644
--- 
a/components/camel-mllp/src/main/java/org/apache/camel/component/mllp/internal/TcpSocketConsumerRunnable.java
+++ 
b/components/camel-mllp/src/main/java/org/apache/camel/component/mllp/internal/TcpSocketConsumerRunnable.java
@@ -20,8 +20,10 @@ import java.io.IOException;
 import java.net.Socket;
 import java.net.SocketAddress;
 import java.net.SocketTimeoutException;
+import java.util.Arrays;
 
 import org.apache.camel.Route;
+import org.apache.camel.component.mllp.MllpProtocolConstants;
 import org.apache.camel.component.mllp.MllpSocketException;
 import org.apache.camel.component.mllp.MllpTcpServerConsumer;
 import org.apache.camel.spi.UnitOfWork;
@@ -135,12 +137,9 @@ public class TcpSocketConsumerRunnable implements Runnable 
{
 
         log.debug("Starting {} for {}", this.getClass().getSimpleName(), 
combinedAddress);
         try {
-            byte[] hl7MessageBytes = null;
             if (mllpBuffer.hasCompleteEnvelope()) {
-                // If we got a complete message on the validation read, 
process it
-                hl7MessageBytes = mllpBuffer.toMllpPayload();
-                mllpBuffer.reset();
-                consumer.processMessage(hl7MessageBytes, this);
+                // Process all complete messages received during the 
validation read.
+                processBufferedMessages();
             }
 
             while (running && null != clientSocket && 
clientSocket.isConnected() && !clientSocket.isClosed()) {
@@ -148,23 +147,7 @@ public class TcpSocketConsumerRunnable implements Runnable 
{
                 try {
                     mllpBuffer.readFrom(clientSocket);
                     if (mllpBuffer.hasCompleteEnvelope()) {
-                        hl7MessageBytes = mllpBuffer.toMllpPayload();
-                        if (log.isDebugEnabled()) {
-                            log.debug("Received {} byte message {}", 
hl7MessageBytes.length,
-                                    
hl7Util.convertToLoggableString(hl7MessageBytes));
-                        }
-                        if (mllpBuffer.hasLeadingOutOfBandData()) {
-                            // TODO:  Move the conversion utilities to the 
MllpSocketBuffer to avoid a byte[] copy
-                            log.warn("Ignoring leading out-of-band data: {}",
-                                    
hl7Util.convertToLoggableString(mllpBuffer.getLeadingOutOfBandData()));
-                        }
-                        if (mllpBuffer.hasTrailingOutOfBandData()) {
-                            log.warn("Ignoring trailing out-of-band data: {}",
-                                    
hl7Util.convertToLoggableString(mllpBuffer.getTrailingOutOfBandData()));
-                        }
-                        mllpBuffer.reset();
-
-                        consumer.processMessage(hl7MessageBytes, this);
+                        processBufferedMessages();
                     } else if (!mllpBuffer.hasStartOfBlock()) {
                         byte[] payload = mllpBuffer.toByteArray();
                         log.warn("Ignoring {} byte un-enveloped payload {}", 
payload.length,
@@ -217,6 +200,87 @@ public class TcpSocketConsumerRunnable implements Runnable 
{
         }
     }
 
+    /**
+     * Process all complete MLLP envelopes currently in the buffer.
+     * <p>
+     * The buffer is reused to generate and send the acknowledgement. Preserve 
a trailing framed message before
+     * processing the current one and restore it afterwards, so pipelined 
messages are not lost when the buffer is reset
+     * for the acknowledgement.
+     */
+    private void processBufferedMessages() {
+        do {
+            byte[] hl7MessageBytes = mllpBuffer.toMllpPayload();
+            if (log.isDebugEnabled()) {
+                log.debug("Received {} byte message {}", 
hl7MessageBytes.length,
+                        hl7Util.convertToLoggableString(hl7MessageBytes));
+            }
+            if (mllpBuffer.hasLeadingOutOfBandData()) {
+                log.warn("Ignoring leading out-of-band data: {}",
+                        
hl7Util.convertToLoggableString(mllpBuffer.getLeadingOutOfBandData()));
+            }
+
+            byte[] trailingMessageData = extractTrailingMessageData();
+            mllpBuffer.reset();
+            consumer.processMessage(hl7MessageBytes, this);
+
+            if (trailingMessageData != null) {
+                mllpBuffer.reset();
+                mllpBuffer.write(trailingMessageData);
+            }
+        } while (isSocketOpen() && mllpBuffer.hasCompleteEnvelope());
+
+        if (!mllpBuffer.isEmpty() && !isSocketOpen()) {
+            log.warn("Abandoning {} bytes of unprocessed pipelined data 
because the connection is closed",
+                    mllpBuffer.size());
+        }
+    }
+
+    /**
+     * Return trailing data beginning with the next START_OF_BLOCK, if 
present. Data preceding it, and trailing data
+     * with no START_OF_BLOCK, is out-of-band data.
+     */
+    private byte[] extractTrailingMessageData() {
+        if (!mllpBuffer.hasTrailingOutOfBandData()) {
+            return null;
+        }
+
+        byte[] trailingData = mllpBuffer.getTrailingOutOfBandData();
+        int trailingDataOffset = mllpBuffer.size() - trailingData.length;
+        int leadingLineBreakCount = 0;
+        while (leadingLineBreakCount < trailingData.length && 
isLineBreak(trailingData[leadingLineBreakCount])) {
+            leadingLineBreakCount++;
+        }
+        for (int i = 0; i < trailingData.length; i++) {
+            if (trailingData[i] == MllpProtocolConstants.START_OF_BLOCK) {
+                // A START_OF_BLOCK preceded only by line breaks (e.g. senders 
terminating frames with <FS><CR><LF>)
+                // starts the next message, even if that message is not 
complete yet.
+                if (i == leadingLineBreakCount || 
mllpBuffer.isCompleteEnvelopeAt(trailingDataOffset + i)) {
+                    if (i > 0) {
+                        byte[] outOfBandData = Arrays.copyOf(trailingData, i);
+                        log.warn("Ignoring {} bytes of out-of-band data before 
next message: {}", i,
+                                
hl7Util.convertToLoggableString(outOfBandData));
+                    }
+                    return Arrays.copyOfRange(trailingData, i, 
trailingData.length);
+                }
+
+                // A START_OF_BLOCK embedded in junk must not be retained as a 
partial message: doing so would
+                // turn a benign trailing-data warning into a receive-timeout 
error and reset the connection.
+                continue;
+            }
+        }
+
+        log.warn("Ignoring trailing out-of-band data: {}", 
hl7Util.convertToLoggableString(trailingData));
+        return null;
+    }
+
+    private static boolean isLineBreak(byte b) {
+        return b == '\r' || b == '\n';
+    }
+
+    private boolean isSocketOpen() {
+        return running && clientSocket != null && clientSocket.isConnected() 
&& !clientSocket.isClosed();
+    }
+
     public Socket getSocket() {
         return clientSocket;
     }
diff --git 
a/components/camel-mllp/src/test/java/org/apache/camel/component/mllp/MllpTcpServerConsumerPipelinedMessagesTest.java
 
b/components/camel-mllp/src/test/java/org/apache/camel/component/mllp/MllpTcpServerConsumerPipelinedMessagesTest.java
new file mode 100644
index 000000000000..4a37f0d4d673
--- /dev/null
+++ 
b/components/camel-mllp/src/test/java/org/apache/camel/component/mllp/MllpTcpServerConsumerPipelinedMessagesTest.java
@@ -0,0 +1,133 @@
+/*
+ * 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.component.mllp;
+
+import java.util.concurrent.TimeUnit;
+
+import org.apache.camel.EndpointInject;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.test.AvailablePortFinder;
+import org.apache.camel.test.junit.rule.mllp.MllpClientResource;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.camel.test.mllp.Hl7TestMessageGenerator;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+public class MllpTcpServerConsumerPipelinedMessagesTest extends 
CamelTestSupport {
+    @RegisterExtension
+    public MllpClientResource mllpClient = new MllpClientResource();
+
+    @EndpointInject("mock://result")
+    MockEndpoint result;
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        mllpClient.setMllpHost("localhost");
+        mllpClient.setMllpPort(AvailablePortFinder.getNextAvailable());
+
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                fromF("mllp://%s:%d?minBufferSize=8192", 
mllpClient.getMllpHost(), mllpClient.getMllpPort())
+                        .to(result);
+            }
+        };
+    }
+
+    @Test
+    public void testReceiveTwoPipelinedMessagesFromValidationRead() throws 
Exception {
+        assertPipelinedMessagesReceived(1, 2);
+    }
+
+    @Test
+    public void testReceiveThreePipelinedMessages() throws Exception {
+        assertPipelinedMessagesReceived(1, 2, 3);
+    }
+
+    @Test
+    public void testReceivePipelinedMessagesAfterAcknowledgedMessage() throws 
Exception {
+        mllpClient.connect();
+        String firstMessage = Hl7TestMessageGenerator.generateMessage(1);
+        result.expectedMessageCount(3);
+        result.message(0).body().isEqualTo(firstMessage);
+        mllpClient.sendFramedData(firstMessage);
+        assertAcknowledgement(1);
+
+        String secondMessage = Hl7TestMessageGenerator.generateMessage(2);
+        String thirdMessage = Hl7TestMessageGenerator.generateMessage(3);
+        result.message(1).body().isEqualTo(secondMessage);
+        result.message(2).body().isEqualTo(thirdMessage);
+        mllpClient.sendFramedDataPipelined(secondMessage, thirdMessage);
+        assertAcknowledgement(2);
+        assertAcknowledgement(3);
+        MockEndpoint.assertIsSatisfied(context, 10, TimeUnit.SECONDS);
+    }
+
+    @Test
+    public void testReceivePipelinedMessageAfterJunkContainingStartOfBlock() 
throws Exception {
+        String firstMessage = Hl7TestMessageGenerator.generateMessage(1);
+        String secondMessage = Hl7TestMessageGenerator.generateMessage(2);
+        result.expectedBodiesReceived(firstMessage, secondMessage);
+
+        mllpClient.sendFramedDataPipelined(
+                new byte[] { 'j', 0x0b, 'u', 'n', 'k', 0x1c, 'x' }, 
firstMessage, secondMessage);
+        assertAcknowledgement(1);
+        assertAcknowledgement(2);
+        MockEndpoint.assertIsSatisfied(context, 10, TimeUnit.SECONDS);
+    }
+
+    @Test
+    public void testReceivePipelinedMessageSpanningReadsAfterLineFeed() throws 
Exception {
+        String firstMessage = Hl7TestMessageGenerator.generateMessage(1);
+        StringBuilder secondMessage = new 
StringBuilder(Hl7TestMessageGenerator.generateMessage(2));
+        // Larger than the read buffer, so the second message is incomplete 
when the first one is processed
+        for (int i = 1; secondMessage.length() < 32 * 1024; i++) {
+            
secondMessage.append("OBX|").append(i).append("|TX|NOTE^Note||Lorem ipsum dolor 
sit amet||||||F\r");
+        }
+        result.expectedBodiesReceived(firstMessage, secondMessage.toString());
+
+        // Some senders terminate frames with <FS><CR><LF>
+        mllpClient.sendFramedDataPipelined(new byte[] { '\n' }, firstMessage, 
secondMessage.toString());
+        assertAcknowledgement(1);
+        assertAcknowledgement(2);
+        MockEndpoint.assertIsSatisfied(context, 10, TimeUnit.SECONDS);
+    }
+
+    private void assertPipelinedMessagesReceived(int... messageNumbers) throws 
Exception {
+        String[] messages = new String[messageNumbers.length];
+        result.expectedMessageCount(messageNumbers.length);
+        for (int i = 0; i < messageNumbers.length; i++) {
+            messages[i] = 
Hl7TestMessageGenerator.generateMessage(messageNumbers[i]);
+            result.message(i).body().isEqualTo(messages[i]);
+        }
+
+        mllpClient.sendFramedDataPipelined(messages);
+        for (int messageNumber : messageNumbers) {
+            assertAcknowledgement(messageNumber);
+        }
+        MockEndpoint.assertIsSatisfied(context, 10, TimeUnit.SECONDS);
+    }
+
+    private void assertAcknowledgement(int messageNumber) throws Exception {
+        assertThat(mllpClient.receiveFramedData(),
+                containsString(String.format("MSA|AA|%05d", messageNumber)));
+    }
+}
diff --git 
a/components/camel-mllp/src/test/java/org/apache/camel/test/junit/rule/mllp/MllpClientResource.java
 
b/components/camel-mllp/src/test/java/org/apache/camel/test/junit/rule/mllp/MllpClientResource.java
index 9ba56232fca8..adc342d28aa7 100644
--- 
a/components/camel-mllp/src/test/java/org/apache/camel/test/junit/rule/mllp/MllpClientResource.java
+++ 
b/components/camel-mllp/src/test/java/org/apache/camel/test/junit/rule/mllp/MllpClientResource.java
@@ -17,6 +17,7 @@
 package org.apache.camel.test.junit.rule.mllp;
 
 import java.io.BufferedOutputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -259,6 +260,59 @@ public class MllpClientResource implements 
BeforeEachCallback, AfterEachCallback
         }
     }
 
+    /**
+     * Send multiple complete MLLP envelopes in a single socket write without 
waiting for their acknowledgements.
+     *
+     * @param hl7Messages messages to send
+     */
+    public void sendFramedDataPipelined(String... hl7Messages) {
+        sendFramedDataPipelined(null, hl7Messages);
+    }
+
+    /**
+     * Send multiple complete MLLP envelopes in a single socket write, 
inserting raw data after the first envelope.
+     *
+     * @param dataAfterFirstMessage raw data inserted after the first envelope
+     * @param hl7Messages           messages to send
+     */
+    public void sendFramedDataPipelined(byte[] dataAfterFirstMessage, 
String... hl7Messages) {
+        if (null == clientSocket) {
+            this.connect();
+        }
+
+        if (!clientSocket.isConnected()) {
+            throw new MllpJUnitResourceException("Cannot send message - client 
is not connected");
+        }
+        if (null == outputStream) {
+            throw new MllpJUnitResourceException("Cannot send message - output 
stream is null");
+        }
+
+        try {
+            ByteArrayOutputStream framedData = new ByteArrayOutputStream();
+            for (int i = 0; i < hl7Messages.length; i++) {
+                String hl7Message = hl7Messages[i];
+                if (sendStartOfBlock) {
+                    framedData.write(START_OF_BLOCK);
+                }
+                framedData.write(hl7Message.getBytes());
+                if (sendEndOfBlock) {
+                    framedData.write(END_OF_BLOCK);
+                }
+                if (sendEndOfData) {
+                    framedData.write(END_OF_DATA);
+                }
+                if (i == 0 && dataAfterFirstMessage != null) {
+                    framedData.write(dataAfterFirstMessage);
+                }
+            }
+            outputStream.write(framedData.toByteArray());
+            outputStream.flush();
+        } catch (IOException e) {
+            log.error("Unable to send pipelined HL7 messages", e);
+            throw new MllpJUnitResourceException("Unable to send pipelined HL7 
messages", e);
+        }
+    }
+
     public void sendFramedDataInMultiplePackets(String hl7Message, byte 
flushByte) {
         sendFramedDataInMultiplePackets(hl7Message, flushByte, false);
     }

Reply via email to