Skip to content

Commit dce599f

Browse files
authored
Fix network bridge silent death when transport exception occurs during broker info handshake (#1865)
Remove early return in onException() handlers for local and remote transports in DemandForwardingBridgeSupport. When futureBrokerInfo was not done, the handler cancelled the future and returned without calling serviceLocalException()/serviceRemoteException(), preventing the reconnection chain from firing in some cases. This prevents relying on only the timeout connection on the future from triggering reconnect and passes the original error.
1 parent 467ef36 commit dce599f

2 files changed

Lines changed: 331 additions & 2 deletions

File tree

activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,6 @@ public void onException(IOException error) {
228228
LOG.info("Error with pending local brokerInfo on: {} ({})", localBroker, error.getMessage());
229229
LOG.debug("Peer error: ", error);
230230
futureLocalBrokerInfo.cancel(true);
231-
return;
232231
}
233232
serviceLocalException(error);
234233
}
@@ -248,7 +247,6 @@ public void onException(IOException error) {
248247
LOG.info("Error with pending remote brokerInfo on: {} ({})", remoteBroker, error.getMessage());
249248
LOG.debug("Peer error: ", error);
250249
futureRemoteBrokerInfo.cancel(true);
251-
return;
252250
}
253251
serviceRemoteException(error);
254252
}
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.activemq.network;
18+
19+
import static org.junit.Assert.assertNotNull;
20+
import static org.junit.Assert.assertTrue;
21+
22+
import java.io.IOException;
23+
import java.net.ServerSocket;
24+
import java.net.Socket;
25+
import java.net.URI;
26+
import java.util.concurrent.CopyOnWriteArrayList;
27+
import java.util.concurrent.CountDownLatch;
28+
import java.util.concurrent.TimeUnit;
29+
import java.util.concurrent.atomic.AtomicReference;
30+
31+
import jakarta.jms.Connection;
32+
import jakarta.jms.MessageConsumer;
33+
import jakarta.jms.MessageProducer;
34+
import jakarta.jms.Session;
35+
import jakarta.jms.TextMessage;
36+
37+
import org.apache.activemq.ActiveMQConnectionFactory;
38+
import org.apache.activemq.broker.BrokerService;
39+
import org.apache.activemq.command.ActiveMQQueue;
40+
import org.apache.activemq.transport.Transport;
41+
import org.apache.activemq.util.Wait;
42+
import org.junit.After;
43+
import org.junit.Test;
44+
import org.slf4j.Logger;
45+
import org.slf4j.LoggerFactory;
46+
47+
/**
48+
* Test that verifies network bridges properly handle transport exceptions
49+
* during the broker info handshake phase and recover by reconnecting
50+
* to a real broker.
51+
*
52+
* <p>The bug: {@code onException()} in {@link DemandForwardingBridgeSupport}
53+
* returned early when {@code futureBrokerInfo} was not done (i.e. during
54+
* the handshake), preventing {@code serviceRemoteException()} from being
55+
* called with the original {@code IOException}. While
56+
* {@code collectBrokerInfos()} provided a fallback reconnection path
57+
* (via {@code TimeoutException}), the original error was lost.</p>
58+
*
59+
* <p>The fix removes the early {@code return} so that
60+
* {@code serviceRemoteException()} is always called with the original
61+
* {@code IOException}, ensuring proper error reporting and direct
62+
* exception handling in {@code onException()}.</p>
63+
*/
64+
public class NetworkBridgeReconnectOnHandshakeFailureTest {
65+
66+
private static final Logger LOG = LoggerFactory.getLogger(NetworkBridgeReconnectOnHandshakeFailureTest.class);
67+
68+
private BrokerService localBroker;
69+
private BrokerService remoteBroker;
70+
71+
@After
72+
public void tearDown() throws Exception {
73+
if (localBroker != null) {
74+
try { localBroker.stop(); } catch (Exception ignored) {}
75+
localBroker.waitUntilStopped();
76+
}
77+
if (remoteBroker != null) {
78+
try { remoteBroker.stop(); } catch (Exception ignored) {}
79+
remoteBroker.waitUntilStopped();
80+
}
81+
}
82+
83+
/**
84+
* Simulates a handshake failure end-to-end and verifies recovery:
85+
*
86+
* <ol>
87+
* <li>A fake server accepts TCP connections but never sends
88+
* {@code BrokerInfo}, so {@code futureBrokerInfo} is never
89+
* completed — the bridge is stuck mid-handshake.</li>
90+
* <li>The fake server abruptly closes the socket, triggering
91+
* {@code onException()} while {@code futureBrokerInfo} is
92+
* not done — the exact bug path.</li>
93+
* <li>A real broker starts on the same port.</li>
94+
* <li>The bridge reconnects to the real broker.</li>
95+
* <li>Messages flow across the re-established bridge.</li>
96+
* </ol>
97+
*
98+
* <p>Additionally, this test uses a custom {@link BridgeFactory} to
99+
* verify that {@code serviceRemoteException()} receives the original
100+
* {@code IOException} (from {@code onException()}), not only a
101+
* {@code TimeoutException} (from the {@code collectBrokerInfos()}
102+
* fallback). Without the fix, the early {@code return} prevents the
103+
* {@code IOException} from reaching {@code serviceRemoteException()}.
104+
* </p>
105+
*/
106+
@Test(timeout = 60_000)
107+
public void testBridgeReconnectsAfterHandshakeFailure() throws Exception {
108+
// Track exceptions passed to serviceRemoteException to verify
109+
// the original IOException is properly propagated
110+
CopyOnWriteArrayList<Throwable> remoteExceptions = new CopyOnWriteArrayList<>();
111+
CountDownLatch exceptionLatch = new CountDownLatch(1);
112+
113+
BridgeFactory trackingFactory = new BridgeFactory() {
114+
@Override
115+
public DemandForwardingBridge createNetworkBridge(
116+
NetworkBridgeConfiguration configuration,
117+
Transport localTransport, Transport remoteTransport,
118+
NetworkBridgeListener listener) {
119+
DemandForwardingBridge bridge = new DemandForwardingBridge(configuration, localTransport, remoteTransport) {
120+
@Override
121+
public void serviceRemoteException(Throwable error) {
122+
LOG.info("serviceRemoteException called with: {} ({})",
123+
error.getClass().getSimpleName(), error.getMessage());
124+
remoteExceptions.add(error);
125+
exceptionLatch.countDown();
126+
super.serviceRemoteException(error);
127+
}
128+
};
129+
bridge.setNetworkBridgeListener(listener);
130+
return bridge;
131+
}
132+
};
133+
134+
// Phase 1: Start a fake server that accepts connections but never
135+
// sends BrokerInfo — this keeps futureBrokerInfo incomplete
136+
ServerSocket fakeServer = new ServerSocket(0);
137+
int port = fakeServer.getLocalPort();
138+
LOG.info("Fake server listening on port {}", port);
139+
140+
CountDownLatch connectionReceived = new CountDownLatch(1);
141+
AtomicReference<Socket> clientSocket = new AtomicReference<>();
142+
Thread acceptThread = new Thread(() -> {
143+
try {
144+
while (!Thread.currentThread().isInterrupted()) {
145+
Socket s = fakeServer.accept();
146+
LOG.info("Fake server accepted connection from {}", s.getRemoteSocketAddress());
147+
clientSocket.set(s);
148+
connectionReceived.countDown();
149+
}
150+
} catch (Exception e) {
151+
// Expected when we close the server socket
152+
}
153+
}, "fake-server-accept");
154+
acceptThread.setDaemon(true);
155+
acceptThread.start();
156+
157+
// Start the local broker with network connector pointing at the fake server
158+
localBroker = new BrokerService();
159+
localBroker.setBrokerName("localBroker");
160+
localBroker.setUseJmx(false);
161+
localBroker.setPersistent(false);
162+
localBroker.setUseShutdownHook(false);
163+
DiscoveryNetworkConnector nc = new DiscoveryNetworkConnector(
164+
new URI("static:(tcp://localhost:" + port
165+
+ "?wireFormat.maxInactivityDuration=0"
166+
+ ")?useExponentialBackOff=false&initialReconnectDelay=1000"));
167+
nc.setName("bridge-handshake-failure-test");
168+
nc.setBridgeFactory(trackingFactory);
169+
localBroker.addNetworkConnector(nc);
170+
localBroker.start();
171+
localBroker.waitUntilStarted();
172+
173+
// Wait for the bridge to TCP-connect to the fake server.
174+
// At this point futureBrokerInfo is NOT done — the handshake
175+
// is stuck because the fake server never sends BrokerInfo.
176+
assertTrue("Bridge should connect to fake server",
177+
connectionReceived.await(10, TimeUnit.SECONDS));
178+
LOG.info("Bridge connected to fake server, futureBrokerInfo will NOT be set");
179+
180+
// Phase 2: Simulate a handshake failure — close the socket to
181+
// trigger onException() while futureBrokerInfo is not done
182+
Socket s = clientSocket.get();
183+
if (s != null) {
184+
s.close();
185+
LOG.info("Closed fake server socket — simulating handshake failure");
186+
}
187+
188+
// Verify serviceRemoteException is called with the original IOException.
189+
// Without the fix, only a TimeoutException from collectBrokerInfos
190+
// would reach serviceRemoteException.
191+
assertTrue("serviceRemoteException should be called",
192+
exceptionLatch.await(10, TimeUnit.SECONDS));
193+
194+
// Allow time for both code paths (onException and collectBrokerInfos)
195+
// to call serviceRemoteException
196+
assertTrue("Should receive exception(s)", Wait.waitFor(() ->
197+
!remoteExceptions.isEmpty(), 5_000, 100));
198+
199+
for (int i = 0; i < remoteExceptions.size(); i++) {
200+
Throwable ex = remoteExceptions.get(i);
201+
LOG.info("serviceRemoteException call [{}]: {} ({})",
202+
i, ex.getClass().getName(), ex.getMessage());
203+
}
204+
205+
boolean hasIOException = remoteExceptions.stream()
206+
.anyMatch(ex -> ex instanceof IOException);
207+
assertTrue(
208+
"serviceRemoteException should receive the original IOException "
209+
+ "(from onException handler), not only TimeoutException "
210+
+ "(from collectBrokerInfos fallback). Exceptions received: "
211+
+ remoteExceptions.stream()
212+
.map(ex -> ex.getClass().getSimpleName())
213+
.reduce((a, b) -> a + ", " + b).orElse("none"),
214+
hasIOException);
215+
216+
// Phase 3: Shut down the fake server and start a real broker
217+
// on the same port
218+
fakeServer.close();
219+
acceptThread.interrupt();
220+
221+
remoteBroker = new BrokerService();
222+
remoteBroker.setBrokerName("remoteBroker");
223+
remoteBroker.setUseJmx(false);
224+
remoteBroker.setPersistent(false);
225+
remoteBroker.setUseShutdownHook(false);
226+
remoteBroker.addConnector("tcp://localhost:" + port);
227+
remoteBroker.start();
228+
remoteBroker.waitUntilStarted();
229+
LOG.info("Real remote broker started on port {}", port);
230+
231+
// Phase 4: The bridge should reconnect to the real broker
232+
assertTrue("Bridge should reconnect to real broker after handshake failure",
233+
Wait.waitFor(() -> !nc.activeBridges().isEmpty(), 30_000, 500));
234+
LOG.info("Bridge reconnected successfully after handshake failure");
235+
236+
// Phase 5: Verify messages flow across the re-established bridge
237+
verifyMessageFlow(localBroker, remoteBroker);
238+
}
239+
240+
/**
241+
* Verify that when the remote broker is abruptly stopped (causing a
242+
* transport exception potentially during the broker info handshake),
243+
* the network bridge reconnects once the remote broker is restarted.
244+
*/
245+
@Test(timeout = 60_000)
246+
public void testBridgeReconnectsAfterRemoteBrokerRestart() throws Exception {
247+
remoteBroker = createRemoteBroker(0);
248+
remoteBroker.start();
249+
remoteBroker.waitUntilStarted();
250+
int remotePort = remoteBroker.getTransportConnectors().get(0).getConnectUri().getPort();
251+
252+
localBroker = createLocalBroker(remotePort);
253+
localBroker.start();
254+
localBroker.waitUntilStarted();
255+
DiscoveryNetworkConnector nc = (DiscoveryNetworkConnector) localBroker.getNetworkConnectors().get(0);
256+
257+
assertTrue("Bridge should be established", Wait.waitFor(() ->
258+
!nc.activeBridges().isEmpty(), 15_000, 200));
259+
260+
remoteBroker.stop();
261+
remoteBroker.waitUntilStopped();
262+
263+
assertTrue("Bridge should go down", Wait.waitFor(() ->
264+
nc.activeBridges().isEmpty(), 10_000, 200));
265+
266+
remoteBroker = createRemoteBroker(remotePort);
267+
remoteBroker.start();
268+
remoteBroker.waitUntilStarted();
269+
270+
assertTrue("Bridge should reconnect", Wait.waitFor(() ->
271+
!nc.activeBridges().isEmpty(), 30_000, 500));
272+
273+
verifyMessageFlow(localBroker, remoteBroker);
274+
}
275+
276+
private BrokerService createRemoteBroker(int port) throws Exception {
277+
BrokerService broker = new BrokerService();
278+
broker.setBrokerName("remoteBroker");
279+
broker.setUseJmx(false);
280+
broker.setPersistent(false);
281+
broker.setUseShutdownHook(false);
282+
broker.addConnector("tcp://localhost:" + port);
283+
return broker;
284+
}
285+
286+
private BrokerService createLocalBroker(int remotePort) throws Exception {
287+
BrokerService broker = new BrokerService();
288+
broker.setBrokerName("localBroker");
289+
broker.setUseJmx(false);
290+
broker.setPersistent(false);
291+
broker.setUseShutdownHook(false);
292+
DiscoveryNetworkConnector nc = new DiscoveryNetworkConnector(
293+
new URI("static:(tcp://localhost:" + remotePort + ")?useExponentialBackOff=false&initialReconnectDelay=1000"));
294+
nc.setName("bridge-reconnect-test");
295+
broker.addNetworkConnector(nc);
296+
return broker;
297+
}
298+
299+
private void verifyMessageFlow(BrokerService local, BrokerService remote) throws Exception {
300+
ActiveMQQueue dest = new ActiveMQQueue("RECONNECT.HANDSHAKE.TEST");
301+
302+
ActiveMQConnectionFactory remoteFac = new ActiveMQConnectionFactory(remote.getVmConnectorURI());
303+
Connection remoteConn = remoteFac.createConnection();
304+
remoteConn.start();
305+
Session remoteSession = remoteConn.createSession(false, Session.AUTO_ACKNOWLEDGE);
306+
MessageConsumer consumer = remoteSession.createConsumer(dest);
307+
308+
assertTrue("Demand subscription should propagate", Wait.waitFor(() -> {
309+
try {
310+
return local.getDestination(dest) != null
311+
&& local.getDestination(dest).getConsumers().size() > 0;
312+
} catch (Exception e) {
313+
return false;
314+
}
315+
}, 30_000, 200));
316+
317+
ActiveMQConnectionFactory localFac = new ActiveMQConnectionFactory(local.getVmConnectorURI());
318+
Connection localConn = localFac.createConnection();
319+
localConn.start();
320+
Session localSession = localConn.createSession(false, Session.AUTO_ACKNOWLEDGE);
321+
MessageProducer producer = localSession.createProducer(dest);
322+
producer.send(localSession.createTextMessage("test-after-reconnect"));
323+
producer.close();
324+
325+
TextMessage received = (TextMessage) consumer.receive(TimeUnit.SECONDS.toMillis(10));
326+
assertNotNull("Message should flow across the re-established bridge", received);
327+
328+
localConn.close();
329+
remoteConn.close();
330+
}
331+
}

0 commit comments

Comments
 (0)