From f4aba0bdb6de4ccd6c2c576eec7f98fed55949f9 Mon Sep 17 00:00:00 2001 From: Nikita Kokitkar Date: Thu, 13 Aug 2026 12:32:03 -0700 Subject: [PATCH] Fix Paho client cleanup after failed startup Force-close internally owned MQTT v3 and v5 clients after startup or shutdown failures while preserving shared-client ownership. Keep the primary lifecycle exception and suppress any close failure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- components/camel-paho-mqtt5/pom.xml | 5 + .../paho/mqtt5/PahoMqtt5Consumer.java | 174 +++++++++++------- .../mqtt5/PahoMqtt5ConsumerLifecycleTest.java | 142 ++++++++++++++ components/camel-paho/pom.xml | 5 + .../camel/component/paho/PahoConsumer.java | 158 ++++++++++------ .../paho/PahoConsumerLifecycleTest.java | 142 ++++++++++++++ 6 files changed, 500 insertions(+), 126 deletions(-) create mode 100644 components/camel-paho-mqtt5/src/test/java/org/apache/camel/component/paho/mqtt5/PahoMqtt5ConsumerLifecycleTest.java create mode 100644 components/camel-paho/src/test/java/org/apache/camel/component/paho/PahoConsumerLifecycleTest.java diff --git a/components/camel-paho-mqtt5/pom.xml b/components/camel-paho-mqtt5/pom.xml index 95213382a046e..5ce845becdf67 100644 --- a/components/camel-paho-mqtt5/pom.xml +++ b/components/camel-paho-mqtt5/pom.xml @@ -52,6 +52,11 @@ camel-test-junit6 test + + org.assertj + assertj-core + test + diff --git a/components/camel-paho-mqtt5/src/main/java/org/apache/camel/component/paho/mqtt5/PahoMqtt5Consumer.java b/components/camel-paho-mqtt5/src/main/java/org/apache/camel/component/paho/mqtt5/PahoMqtt5Consumer.java index ef6413031742f..e15df423a9be6 100644 --- a/components/camel-paho-mqtt5/src/main/java/org/apache/camel/component/paho/mqtt5/PahoMqtt5Consumer.java +++ b/components/camel-paho-mqtt5/src/main/java/org/apache/camel/component/paho/mqtt5/PahoMqtt5Consumer.java @@ -58,91 +58,131 @@ public void setClient(MqttClient client) { protected void doStart() throws Exception { super.doStart(); - connectionOptions = getEndpoint().createMqttConnectionOptions(); - - if (client == null) { - clientId = getEndpoint().getConfiguration().getClientId(); - if (clientId == null) { - clientId = PahoMqtt5Endpoint.generateClientId(); - } - stopClient = true; - client = new MqttClient( - getEndpoint().getConfiguration().getBrokerUrl(), - clientId, - PahoMqtt5Endpoint.createMqttClientPersistence(getEndpoint().getConfiguration())); - LOG.debug("Connecting client: {} to broker: {}", clientId, getEndpoint().getConfiguration().getBrokerUrl()); - if (getEndpoint().getConfiguration().isManualAcksEnabled()) { - client.setManualAcks(true); - + stopClient = client == null; + try { + connectionOptions = getEndpoint().createMqttConnectionOptions(); + + if (stopClient) { + clientId = getEndpoint().getConfiguration().getClientId(); + if (clientId == null) { + clientId = PahoMqtt5Endpoint.generateClientId(); + } + client = createClient(); + LOG.debug("Connecting client: {} to broker: {}", clientId, getEndpoint().getConfiguration().getBrokerUrl()); + if (getEndpoint().getConfiguration().isManualAcksEnabled()) { + client.setManualAcks(true); + } + client.connect(connectionOptions); } - client.connect(connectionOptions); - } - client.setCallback(new MqttCallback() { + client.setCallback(new MqttCallback() { - @Override - public void connectComplete(boolean reconnect, String serverURI) { - if (reconnect) { - try { - client.subscribe(getEndpoint().getTopic(), getEndpoint().getConfiguration().getQos()); - } catch (MqttException e) { - LOG.error("MQTT resubscribe failed {}", e.getMessage(), e); + @Override + public void connectComplete(boolean reconnect, String serverURI) { + if (reconnect) { + try { + client.subscribe(getEndpoint().getTopic(), getEndpoint().getConfiguration().getQos()); + } catch (MqttException e) { + LOG.error("MQTT resubscribe failed {}", e.getMessage(), e); + } } } - } - @Override - public void authPacketArrived(int reasonCode, MqttProperties properties) { - LOG.debug("Auth packet arrived {} {}", reasonCode, properties); - } + @Override + public void authPacketArrived(int reasonCode, MqttProperties properties) { + LOG.debug("Auth packet arrived {} {}", reasonCode, properties); + } - @Override - public void disconnected(MqttDisconnectResponse response) { - LOG.debug("MQTT broker disconnected due {}", response.getReasonString(), response.getException()); - } + @Override + public void disconnected(MqttDisconnectResponse response) { + LOG.debug("MQTT broker disconnected due {}", response.getReasonString(), response.getException()); + } - @Override - public void mqttErrorOccurred(MqttException exception) { - LOG.debug("Error occurred {}", exception.getMessage(), exception); - } + @Override + public void mqttErrorOccurred(MqttException exception) { + LOG.debug("Error occurred {}", exception.getMessage(), exception); + } - @Override - public void messageArrived(String topic, MqttMessage message) throws Exception { - LOG.debug("Message arrived on topic: {} -> {}", topic, message); - Exchange exchange = createExchange(message, topic); + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + LOG.debug("Message arrived on topic: {} -> {}", topic, message); + Exchange exchange = createExchange(message, topic); - // use default consumer callback - AsyncCallback cb = defaultConsumerCallback(exchange, true); - getAsyncProcessor().process(exchange, cb); - } + // use default consumer callback + AsyncCallback cb = defaultConsumerCallback(exchange, true); + getAsyncProcessor().process(exchange, cb); + } - @Override - public void deliveryComplete(IMqttToken token) { - LOG.debug("Delivery complete. Token: {}", token); - } - }); + @Override + public void deliveryComplete(IMqttToken token) { + LOG.debug("Delivery complete. Token: {}", token); + } + }); - LOG.debug("Subscribing client: {} to topic: {}", clientId, getEndpoint().getTopic()); - client.subscribe(getEndpoint().getTopic(), getEndpoint().getConfiguration().getQos()); + LOG.debug("Subscribing client: {} to topic: {}", clientId, getEndpoint().getTopic()); + client.subscribe(getEndpoint().getTopic(), getEndpoint().getConfiguration().getQos()); + } catch (Exception startException) { + MqttClient ownedClient = stopClient ? client : null; + if (ownedClient != null) { + client = null; + stopClient = false; + closeOwnedClient(ownedClient, startException); + } + throw startException; + } } @Override protected void doStop() throws Exception { - super.doStop(); - - if (stopClient && client != null && client.isConnected()) { - String topic = getEndpoint().getTopic(); - // only unsubscribe if we are not durable - if (getEndpoint().getConfiguration().isCleanStart()) { - LOG.debug("Unsubscribing client: {} from topic: {}", clientId, topic); - client.unsubscribe(topic); - } else { - LOG.debug("Client: {} is durable so will not unsubscribe from topic: {}", clientId, topic); + MqttClient ownedClient = stopClient ? client : null; + Exception stopException = null; + try { + super.doStop(); + + if (ownedClient != null && ownedClient.isConnected()) { + String topic = getEndpoint().getTopic(); + // only unsubscribe if we are not durable + if (getEndpoint().getConfiguration().isCleanStart()) { + LOG.debug("Unsubscribing client: {} from topic: {}", clientId, topic); + ownedClient.unsubscribe(topic); + } else { + LOG.debug("Client: {} is durable so will not unsubscribe from topic: {}", clientId, topic); + } + LOG.debug("Disconnecting client: {} from broker: {}", clientId, + getEndpoint().getConfiguration().getBrokerUrl()); + ownedClient.disconnect(); + } + } catch (Exception e) { + stopException = e; + } finally { + client = null; + stopClient = false; + if (ownedClient != null) { + stopException = closeOwnedClient(ownedClient, stopException); + } + } + if (stopException != null) { + throw stopException; + } + } + + MqttClient createClient() throws MqttException { + return new MqttClient( + getEndpoint().getConfiguration().getBrokerUrl(), + clientId, + PahoMqtt5Endpoint.createMqttClientPersistence(getEndpoint().getConfiguration())); + } + + private Exception closeOwnedClient(MqttClient ownedClient, Exception primaryException) { + try { + ownedClient.close(true); + } catch (Exception closeException) { + if (primaryException == null) { + return closeException; } - LOG.debug("Disconnecting client: {} from broker: {}", clientId, getEndpoint().getConfiguration().getBrokerUrl()); - client.disconnect(); + primaryException.addSuppressed(closeException); } - client = null; + return primaryException; } @Override diff --git a/components/camel-paho-mqtt5/src/test/java/org/apache/camel/component/paho/mqtt5/PahoMqtt5ConsumerLifecycleTest.java b/components/camel-paho-mqtt5/src/test/java/org/apache/camel/component/paho/mqtt5/PahoMqtt5ConsumerLifecycleTest.java new file mode 100644 index 0000000000000..182bc06d72e9e --- /dev/null +++ b/components/camel-paho-mqtt5/src/test/java/org/apache/camel/component/paho/mqtt5/PahoMqtt5ConsumerLifecycleTest.java @@ -0,0 +1,142 @@ +/* + * 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.paho.mqtt5; + +import org.apache.camel.CamelContext; +import org.apache.camel.ExtendedCamelContext; +import org.apache.camel.Processor; +import org.apache.camel.spi.ExchangeFactory; +import org.eclipse.paho.mqttv5.client.MqttClient; +import org.eclipse.paho.mqttv5.client.MqttConnectionOptions; +import org.eclipse.paho.mqttv5.common.MqttException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PahoMqtt5ConsumerLifecycleTest { + + @Test + void failedStartForceClosesOwnedClient() throws Exception { + MqttClient client = mock(MqttClient.class); + MqttException connectException = new MqttException(MqttException.REASON_CODE_CLIENT_EXCEPTION); + doThrow(connectException).when(client).connect(any(MqttConnectionOptions.class)); + PahoMqtt5Consumer consumer = createConsumer(new PahoMqtt5Configuration(), client); + + MqttException thrown = catchThrowableOfType(consumer::doStart, MqttException.class); + + assertThat(thrown).isSameAs(connectException); + verify(client).close(true); + } + + @Test + void failedStartSuppressesCloseFailure() throws Exception { + MqttClient client = mock(MqttClient.class); + MqttException connectException = new MqttException(MqttException.REASON_CODE_CLIENT_EXCEPTION); + MqttException closeException = new MqttException(1); + doThrow(connectException).when(client).connect(any(MqttConnectionOptions.class)); + doThrow(closeException).when(client).close(true); + PahoMqtt5Consumer consumer = createConsumer(new PahoMqtt5Configuration(), client); + + MqttException thrown = catchThrowableOfType(consumer::doStart, MqttException.class); + + assertThat(thrown).isSameAs(connectException); + assertThat(thrown.getSuppressed()).containsExactly(closeException); + } + + @Test + void stopForceClosesOwnedClientWhenDisconnected() throws Exception { + MqttClient client = mock(MqttClient.class); + PahoMqtt5Consumer consumer = createConsumer(new PahoMqtt5Configuration(), client); + consumer.doStart(); + + consumer.doStop(); + + verify(client, never()).disconnect(); + verify(client).close(true); + } + + @Test + void failedStopSuppressesCloseFailure() throws Exception { + MqttClient client = mock(MqttClient.class); + MqttException disconnectException = new MqttException(1); + MqttException closeException = new MqttException(2); + when(client.isConnected()).thenReturn(true); + doThrow(disconnectException).when(client).disconnect(); + doThrow(closeException).when(client).close(true); + PahoMqtt5Configuration configuration = new PahoMqtt5Configuration(); + configuration.setCleanStart(false); + PahoMqtt5Consumer consumer = createConsumer(configuration, client); + consumer.doStart(); + + MqttException thrown = catchThrowableOfType(consumer::doStop, MqttException.class); + + assertThat(thrown).isSameAs(disconnectException); + assertThat(thrown.getSuppressed()).containsExactly(closeException); + } + + @Test + void durableConnectedClientDisconnectsAndClosesWithoutUnsubscribe() throws Exception { + MqttClient client = mock(MqttClient.class); + when(client.isConnected()).thenReturn(true); + PahoMqtt5Configuration configuration = new PahoMqtt5Configuration(); + configuration.setCleanStart(false); + PahoMqtt5Consumer consumer = createConsumer(configuration, client); + consumer.doStart(); + + consumer.doStop(); + + verify(client, never()).unsubscribe("test"); + verify(client).disconnect(); + verify(client).close(true); + } + + @Test + void sharedClientIsNotClosed() throws Exception { + MqttClient client = mock(MqttClient.class); + PahoMqtt5Consumer consumer = createConsumer(new PahoMqtt5Configuration(), mock(MqttClient.class)); + consumer.setClient(client); + + consumer.doStart(); + consumer.doStop(); + + verify(client, never()).close(true); + } + + private static PahoMqtt5Consumer createConsumer(PahoMqtt5Configuration configuration, MqttClient createdClient) { + CamelContext context = mock(CamelContext.class); + ExtendedCamelContext extension = mock(ExtendedCamelContext.class); + ExchangeFactory exchangeFactory = mock(ExchangeFactory.class); + when(context.getCamelContextExtension()).thenReturn(extension); + when(extension.getExchangeFactory()).thenReturn(exchangeFactory); + when(exchangeFactory.newExchangeFactory(any())).thenReturn(exchangeFactory); + PahoMqtt5Endpoint endpoint = new PahoMqtt5Endpoint( + "paho-mqtt5:test", "test", new PahoMqtt5Component(context), configuration); + return new PahoMqtt5Consumer(endpoint, mock(Processor.class)) { + @Override + MqttClient createClient() { + return createdClient; + } + }; + } +} diff --git a/components/camel-paho/pom.xml b/components/camel-paho/pom.xml index 3c4c7895b7172..d5804f694efab 100644 --- a/components/camel-paho/pom.xml +++ b/components/camel-paho/pom.xml @@ -54,6 +54,11 @@ camel-test-junit6 test + + org.assertj + assertj-core + test + org.apache.camel diff --git a/components/camel-paho/src/main/java/org/apache/camel/component/paho/PahoConsumer.java b/components/camel-paho/src/main/java/org/apache/camel/component/paho/PahoConsumer.java index 4bde73ec28e85..e2e1658f981c7 100644 --- a/components/camel-paho/src/main/java/org/apache/camel/component/paho/PahoConsumer.java +++ b/components/camel-paho/src/main/java/org/apache/camel/component/paho/PahoConsumer.java @@ -57,81 +57,121 @@ public void setClient(MqttClient client) { protected void doStart() throws Exception { super.doStart(); - connectOptions = PahoEndpoint.createMqttConnectOptions(getEndpoint().getConfiguration()); - - if (client == null) { - clientId = getEndpoint().getConfiguration().getClientId(); - if (clientId == null) { - clientId = "camel-" + MqttClient.generateClientId(); - } - stopClient = true; - client = new MqttClient( - getEndpoint().getConfiguration().getBrokerUrl(), - clientId, - PahoEndpoint.createMqttClientPersistence(getEndpoint().getConfiguration())); - LOG.debug("Connecting client: {} to broker: {}", clientId, getEndpoint().getConfiguration().getBrokerUrl()); - if (getEndpoint().getConfiguration().isManualAcksEnabled()) { - client.setManualAcks(true); - + stopClient = client == null; + try { + connectOptions = PahoEndpoint.createMqttConnectOptions(getEndpoint().getConfiguration()); + + if (stopClient) { + clientId = getEndpoint().getConfiguration().getClientId(); + if (clientId == null) { + clientId = "camel-" + MqttClient.generateClientId(); + } + client = createClient(); + LOG.debug("Connecting client: {} to broker: {}", clientId, getEndpoint().getConfiguration().getBrokerUrl()); + if (getEndpoint().getConfiguration().isManualAcksEnabled()) { + client.setManualAcks(true); + } + client.connect(connectOptions); } - client.connect(connectOptions); - } - client.setCallback(new MqttCallbackExtended() { + client.setCallback(new MqttCallbackExtended() { - @Override - public void connectComplete(boolean reconnect, String serverURI) { - if (reconnect) { - try { - client.subscribe(getEndpoint().getTopic(), getEndpoint().getConfiguration().getQos()); - } catch (MqttException e) { - LOG.error("MQTT resubscribe failed {}", e.getMessage(), e); + @Override + public void connectComplete(boolean reconnect, String serverURI) { + if (reconnect) { + try { + client.subscribe(getEndpoint().getTopic(), getEndpoint().getConfiguration().getQos()); + } catch (MqttException e) { + LOG.error("MQTT resubscribe failed {}", e.getMessage(), e); + } } } - } - @Override - public void connectionLost(Throwable cause) { - LOG.debug("MQTT broker connection lost due {}", cause.getMessage(), cause); - } + @Override + public void connectionLost(Throwable cause) { + LOG.debug("MQTT broker connection lost due {}", cause.getMessage(), cause); + } - @Override - public void messageArrived(String topic, MqttMessage message) throws Exception { - LOG.debug("Message arrived on topic: {} -> {}", topic, message); - Exchange exchange = createExchange(message, topic); + @Override + public void messageArrived(String topic, MqttMessage message) throws Exception { + LOG.debug("Message arrived on topic: {} -> {}", topic, message); + Exchange exchange = createExchange(message, topic); - // use default consumer callback - AsyncCallback cb = defaultConsumerCallback(exchange, true); - getAsyncProcessor().process(exchange, cb); - } + // use default consumer callback + AsyncCallback cb = defaultConsumerCallback(exchange, true); + getAsyncProcessor().process(exchange, cb); + } - @Override - public void deliveryComplete(IMqttDeliveryToken token) { - LOG.debug("Delivery complete. Token: {}", token); - } - }); + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + LOG.debug("Delivery complete. Token: {}", token); + } + }); - LOG.debug("Subscribing client: {} to topic: {}", clientId, getEndpoint().getTopic()); - client.subscribe(getEndpoint().getTopic(), getEndpoint().getConfiguration().getQos()); + LOG.debug("Subscribing client: {} to topic: {}", clientId, getEndpoint().getTopic()); + client.subscribe(getEndpoint().getTopic(), getEndpoint().getConfiguration().getQos()); + } catch (Exception startException) { + MqttClient ownedClient = stopClient ? client : null; + if (ownedClient != null) { + client = null; + stopClient = false; + closeOwnedClient(ownedClient, startException); + } + throw startException; + } } @Override protected void doStop() throws Exception { - super.doStop(); - - if (stopClient && client != null && client.isConnected()) { - String topic = getEndpoint().getTopic(); - // only unsubscribe if we are not durable - if (getEndpoint().getConfiguration().isCleanSession()) { - LOG.debug("Unsubscribing client: {} from topic: {}", clientId, topic); - client.unsubscribe(topic); - } else { - LOG.debug("Client: {} is durable so will not unsubscribe from topic: {}", clientId, topic); + MqttClient ownedClient = stopClient ? client : null; + Exception stopException = null; + try { + super.doStop(); + + if (ownedClient != null && ownedClient.isConnected()) { + String topic = getEndpoint().getTopic(); + // only unsubscribe if we are not durable + if (getEndpoint().getConfiguration().isCleanSession()) { + LOG.debug("Unsubscribing client: {} from topic: {}", clientId, topic); + ownedClient.unsubscribe(topic); + } else { + LOG.debug("Client: {} is durable so will not unsubscribe from topic: {}", clientId, topic); + } + LOG.debug("Disconnecting client: {} from broker: {}", clientId, + getEndpoint().getConfiguration().getBrokerUrl()); + ownedClient.disconnect(); + } + } catch (Exception e) { + stopException = e; + } finally { + client = null; + stopClient = false; + if (ownedClient != null) { + stopException = closeOwnedClient(ownedClient, stopException); + } + } + if (stopException != null) { + throw stopException; + } + } + + MqttClient createClient() throws MqttException { + return new MqttClient( + getEndpoint().getConfiguration().getBrokerUrl(), + clientId, + PahoEndpoint.createMqttClientPersistence(getEndpoint().getConfiguration())); + } + + private Exception closeOwnedClient(MqttClient ownedClient, Exception primaryException) { + try { + ownedClient.close(true); + } catch (Exception closeException) { + if (primaryException == null) { + return closeException; } - LOG.debug("Disconnecting client: {} from broker: {}", clientId, getEndpoint().getConfiguration().getBrokerUrl()); - client.disconnect(); + primaryException.addSuppressed(closeException); } - client = null; + return primaryException; } @Override diff --git a/components/camel-paho/src/test/java/org/apache/camel/component/paho/PahoConsumerLifecycleTest.java b/components/camel-paho/src/test/java/org/apache/camel/component/paho/PahoConsumerLifecycleTest.java new file mode 100644 index 0000000000000..81d8750dd0a23 --- /dev/null +++ b/components/camel-paho/src/test/java/org/apache/camel/component/paho/PahoConsumerLifecycleTest.java @@ -0,0 +1,142 @@ +/* + * 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.paho; + +import org.apache.camel.CamelContext; +import org.apache.camel.ExtendedCamelContext; +import org.apache.camel.Processor; +import org.apache.camel.spi.ExchangeFactory; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PahoConsumerLifecycleTest { + + @Test + void failedStartForceClosesOwnedClient() throws Exception { + MqttClient client = mock(MqttClient.class); + MqttException connectException = new MqttException(MqttException.REASON_CODE_CLIENT_EXCEPTION); + doThrow(connectException).when(client).connect(any(MqttConnectOptions.class)); + PahoConsumer consumer = createConsumer(new PahoConfiguration(), client); + + MqttException thrown = catchThrowableOfType(consumer::doStart, MqttException.class); + + assertThat(thrown).isSameAs(connectException); + verify(client).close(true); + } + + @Test + void failedStartSuppressesCloseFailure() throws Exception { + MqttClient client = mock(MqttClient.class); + MqttException connectException = new MqttException(MqttException.REASON_CODE_CLIENT_EXCEPTION); + MqttException closeException = new MqttException(MqttException.REASON_CODE_CLIENT_DISCONNECTING); + doThrow(connectException).when(client).connect(any(MqttConnectOptions.class)); + doThrow(closeException).when(client).close(true); + PahoConsumer consumer = createConsumer(new PahoConfiguration(), client); + + MqttException thrown = catchThrowableOfType(consumer::doStart, MqttException.class); + + assertThat(thrown).isSameAs(connectException); + assertThat(thrown.getSuppressed()).containsExactly(closeException); + } + + @Test + void stopForceClosesOwnedClientWhenDisconnected() throws Exception { + MqttClient client = mock(MqttClient.class); + PahoConsumer consumer = createConsumer(new PahoConfiguration(), client); + consumer.doStart(); + + consumer.doStop(); + + verify(client, never()).disconnect(); + verify(client).close(true); + } + + @Test + void failedStopSuppressesCloseFailure() throws Exception { + MqttClient client = mock(MqttClient.class); + MqttException disconnectException = new MqttException(MqttException.REASON_CODE_CLIENT_DISCONNECTING); + MqttException closeException = new MqttException(MqttException.REASON_CODE_CLIENT_EXCEPTION); + when(client.isConnected()).thenReturn(true); + doThrow(disconnectException).when(client).disconnect(); + doThrow(closeException).when(client).close(true); + PahoConfiguration configuration = new PahoConfiguration(); + configuration.setCleanSession(false); + PahoConsumer consumer = createConsumer(configuration, client); + consumer.doStart(); + + MqttException thrown = catchThrowableOfType(consumer::doStop, MqttException.class); + + assertThat(thrown).isSameAs(disconnectException); + assertThat(thrown.getSuppressed()).containsExactly(closeException); + } + + @Test + void durableConnectedClientDisconnectsAndClosesWithoutUnsubscribe() throws Exception { + MqttClient client = mock(MqttClient.class); + when(client.isConnected()).thenReturn(true); + PahoConfiguration configuration = new PahoConfiguration(); + configuration.setCleanSession(false); + PahoConsumer consumer = createConsumer(configuration, client); + consumer.doStart(); + + consumer.doStop(); + + verify(client, never()).unsubscribe("test"); + verify(client).disconnect(); + verify(client).close(true); + } + + @Test + void sharedClientIsNotClosed() throws Exception { + MqttClient client = mock(MqttClient.class); + PahoConsumer consumer = createConsumer(new PahoConfiguration(), mock(MqttClient.class)); + consumer.setClient(client); + + consumer.doStart(); + consumer.doStop(); + + verify(client, never()).close(true); + } + + private static PahoConsumer createConsumer(PahoConfiguration configuration, MqttClient createdClient) { + CamelContext context = mock(CamelContext.class); + ExtendedCamelContext extension = mock(ExtendedCamelContext.class); + ExchangeFactory exchangeFactory = mock(ExchangeFactory.class); + when(context.getCamelContextExtension()).thenReturn(extension); + when(extension.getExchangeFactory()).thenReturn(exchangeFactory); + when(exchangeFactory.newExchangeFactory(any())).thenReturn(exchangeFactory); + PahoEndpoint endpoint = new PahoEndpoint( + "paho:test", "test", new PahoComponent(context), configuration); + return new PahoConsumer(endpoint, mock(Processor.class)) { + @Override + MqttClient createClient() { + return createdClient; + } + }; + } +}