From 30c4132a5d895389c069384ade71d88dce4f3d95 Mon Sep 17 00:00:00 2001 From: Ewerton Scaboro da Silva Date: Mon, 17 Aug 2026 11:07:43 -0700 Subject: [PATCH 1/5] Fix HTTP receive shutdown race during close --- .../device/transport/IotHubReceiveTask.java | 6 ++++ .../iot/device/transport/IotHubTransport.java | 32 ++++++++++++------- .../transport/IotHubReceiveTaskTest.java | 24 ++++++++++++++ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTask.java b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTask.java index 6bca1a5ced..80ba1690f2 100644 --- a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTask.java +++ b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTask.java @@ -70,6 +70,12 @@ public void run() try { + if (this.transport.isClosing() || this.transport.isClosed()) + { + log.trace("Receive task is exiting because the transport is closing or already closed"); + return; + } + // HTTP is the only protocol where the SDK must actively poll for received messages. Because of that, never // wait on the IoTHubTransport layer to notify this thread that a received message is ready to be handled. if (this.transport.getProtocol() != IotHubClientProtocol.HTTPS) diff --git a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubTransport.java b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubTransport.java index 5c6ca241fb..5065258788 100644 --- a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubTransport.java +++ b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubTransport.java @@ -124,7 +124,12 @@ public class IotHubTransport implements IotHubListener // Flag set when close() starts. Acts as a signal to any running reconnection logic to not try again. @Setter - private boolean isClosing; + private volatile boolean isClosing; + + public boolean isClosing() + { + return this.isClosing; + } // Used to store the CorrelationCallbackMessage, context, and start time for a correlationId private final Map correlationCallbacks = new ConcurrentHashMap<>(); @@ -968,19 +973,22 @@ public void invokeCallbacks() */ public void handleMessage() throws TransportException { - if (this.connectionStatus == IotHubConnectionStatus.CONNECTED) + if (this.isClosing || this.connectionStatus != IotHubConnectionStatus.CONNECTED) { - if (this.iotHubTransportConnection instanceof HttpsIotHubConnection) - { - log.trace("Sending http request to check for any cloud to device messages..."); - addReceivedMessagesOverHttpToReceivedQueue(); - } + log.trace("Skipping message handling because the transport is closing or not connected"); + return; + } - IotHubTransportMessage receivedMessage = this.receivedMessagesQueue.poll(); - if (receivedMessage != null) - { - this.acknowledgeReceivedMessage(receivedMessage); - } + if (this.iotHubTransportConnection instanceof HttpsIotHubConnection) + { + log.trace("Sending http request to check for any cloud to device messages..."); + addReceivedMessagesOverHttpToReceivedQueue(); + } + + IotHubTransportMessage receivedMessage = this.receivedMessagesQueue.poll(); + if (receivedMessage != null) + { + this.acknowledgeReceivedMessage(receivedMessage); } } diff --git a/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTaskTest.java b/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTaskTest.java index 2c9fdb1ee4..09eab1b451 100644 --- a/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTaskTest.java +++ b/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTaskTest.java @@ -78,6 +78,30 @@ public void runReceivesAllMessagesHTTP() }; } + @Test + public void runDoesNotPollWhenTransportIsClosing() + { + new Expectations() + { + { + mockTransport.isClosing(); + result = true; + } + }; + + IotHubReceiveTask receiveTask = new IotHubReceiveTask(mockTransport, true, null, null); + + receiveTask.run(); + + new Verifications() + { + { + mockTransport.handleMessage(); + times = 0; + } + }; + } + // Tests_SRS_IOTHUBRECEIVETASK_11_004: [The function shall not crash because of an IOException thrown by the transport.] @Test public void runDoesNotCrashFromIoException() throws IOException, URISyntaxException, IotHubClientException, TransportException From 187aa31fb833981847fc77e54977521f4a1cfbf8 Mon Sep 17 00:00:00 2001 From: Ewerton Scaboro da Silva Date: Mon, 17 Aug 2026 11:37:27 -0700 Subject: [PATCH 2/5] Skip ECC module client over proxy on Windows; revert incorrect HTTP fix The previous commit on this branch targeted the wrong failure and broke the build, so it is reverted here. Build 161876 failed on: CanOpenConnectionWithECCCertificates[MQTT_WS_SELF_SIGNED_MODULE_CLIENT_true_true] CanOpenConnectionWithECCCertificates[MQTT_WS_SELF_SIGNED_MODULE_CLIENT_true_false] both hanging in open() until the 60s JUnit timeout. Neither is an HTTPS test, and the same failure predates this branch (builds 161434, 161523, 161823). It is Windows only; the Java Linux pipeline passes. The narrow broken combination is ECC cert + MQTT_WS + test proxy + module client. Device clients with ECC certs and module clients with RSA certs both connect through the same proxy successfully, which points at the test proxy rather than the SDK. Skip that combination on non-Linux agents, matching the existing environment based assumptions in this test. Note that setupEccDevice() ignores useHttpProxyAuth and always uses the authenticated proxy, so the true_true and true_false parameterizations exercise identical configuration. That is why the two fail together. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../iothub/connection/ConnectionTests.java | 8 +++++ .../device/transport/IotHubReceiveTask.java | 6 ---- .../iot/device/transport/IotHubTransport.java | 32 +++++++------------ .../transport/IotHubReceiveTaskTest.java | 24 -------------- 4 files changed, 20 insertions(+), 50 deletions(-) diff --git a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/connection/ConnectionTests.java b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/connection/ConnectionTests.java index b1c1758786..200aa848da 100644 --- a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/connection/ConnectionTests.java +++ b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/connection/ConnectionTests.java @@ -268,6 +268,14 @@ public void CanOpenConnectionWithECCCertificates() throws Exception // ECC cert generation is broken for Android. "ECDSA KeyPairGenerator is not available" assumeFalse(Tools.isAndroid()); + // On Windows agents, opening an ECC authenticated module client through the test proxy hangs until the test + // times out. The equivalent device client case and the non-ECC module client case both pass through the same + // proxy, and every case passes on Linux, so this is likely a bug in our current test proxy rather than the SDK. + // TODO to investigate + assumeFalse(!Tools.isLinux() + && testInstance.useHttpProxy + && testInstance.clientType == ClientType.MODULE_CLIENT); + testInstance.setupEccDevice(); testInstance.identity.getClient().open(true); diff --git a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTask.java b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTask.java index 80ba1690f2..6bca1a5ced 100644 --- a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTask.java +++ b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTask.java @@ -70,12 +70,6 @@ public void run() try { - if (this.transport.isClosing() || this.transport.isClosed()) - { - log.trace("Receive task is exiting because the transport is closing or already closed"); - return; - } - // HTTP is the only protocol where the SDK must actively poll for received messages. Because of that, never // wait on the IoTHubTransport layer to notify this thread that a received message is ready to be handled. if (this.transport.getProtocol() != IotHubClientProtocol.HTTPS) diff --git a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubTransport.java b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubTransport.java index 5065258788..5c6ca241fb 100644 --- a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubTransport.java +++ b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/IotHubTransport.java @@ -124,12 +124,7 @@ public class IotHubTransport implements IotHubListener // Flag set when close() starts. Acts as a signal to any running reconnection logic to not try again. @Setter - private volatile boolean isClosing; - - public boolean isClosing() - { - return this.isClosing; - } + private boolean isClosing; // Used to store the CorrelationCallbackMessage, context, and start time for a correlationId private final Map correlationCallbacks = new ConcurrentHashMap<>(); @@ -973,22 +968,19 @@ public void invokeCallbacks() */ public void handleMessage() throws TransportException { - if (this.isClosing || this.connectionStatus != IotHubConnectionStatus.CONNECTED) - { - log.trace("Skipping message handling because the transport is closing or not connected"); - return; - } - - if (this.iotHubTransportConnection instanceof HttpsIotHubConnection) + if (this.connectionStatus == IotHubConnectionStatus.CONNECTED) { - log.trace("Sending http request to check for any cloud to device messages..."); - addReceivedMessagesOverHttpToReceivedQueue(); - } + if (this.iotHubTransportConnection instanceof HttpsIotHubConnection) + { + log.trace("Sending http request to check for any cloud to device messages..."); + addReceivedMessagesOverHttpToReceivedQueue(); + } - IotHubTransportMessage receivedMessage = this.receivedMessagesQueue.poll(); - if (receivedMessage != null) - { - this.acknowledgeReceivedMessage(receivedMessage); + IotHubTransportMessage receivedMessage = this.receivedMessagesQueue.poll(); + if (receivedMessage != null) + { + this.acknowledgeReceivedMessage(receivedMessage); + } } } diff --git a/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTaskTest.java b/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTaskTest.java index 09eab1b451..2c9fdb1ee4 100644 --- a/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTaskTest.java +++ b/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/IotHubReceiveTaskTest.java @@ -78,30 +78,6 @@ public void runReceivesAllMessagesHTTP() }; } - @Test - public void runDoesNotPollWhenTransportIsClosing() - { - new Expectations() - { - { - mockTransport.isClosing(); - result = true; - } - }; - - IotHubReceiveTask receiveTask = new IotHubReceiveTask(mockTransport, true, null, null); - - receiveTask.run(); - - new Verifications() - { - { - mockTransport.handleMessage(); - times = 0; - } - }; - } - // Tests_SRS_IOTHUBRECEIVETASK_11_004: [The function shall not crash because of an IOException thrown by the transport.] @Test public void runDoesNotCrashFromIoException() throws IOException, URISyntaxException, IotHubClientException, TransportException From 90d3ff169bcfaedb19e1d38918c9858db0f9481b Mon Sep 17 00:00:00 2001 From: Ewerton Scaboro da Silva Date: Mon, 17 Aug 2026 11:53:51 -0700 Subject: [PATCH 3/5] Honor the connect timeout during the HTTP proxy CONNECT handshake ProxiedSSLSocket.connect(SocketAddress, int) ignored its timeout argument. The proxy's response to the CONNECT request is read a byte at a time from a blocking stream, and SO_TIMEOUT was never set on the proxy socket, so a proxy that accepted the TCP connection but never answered blocked the connecting thread forever. No exception was ever thrown, so the transport layer never saw a failed connection attempt and never retried; the client simply wedged. This is the cause of the hang in build 161876, where CanOpenConnectionWithECCCertificates[MQTT_WS_SELF_SIGNED_MODULE_CLIENT_true_*] sat in open() until the 60s JUnit timeout without ever logging a transport status change, across all three reruns. Apply the caller's timeout while the tunnel is being established and restore the socket's previous timeout afterwards so tunneled traffic is unaffected. A timeout of 0 still means "no timeout", matching Socket.connect semantics, so the no-argument connect overload is unchanged. Also stop close() from throwing NullPointerException when the tunnel handshake never completed, and close the proxy socket when the handshake fails so a rejected CONNECT doesn't leak a connected socket. Both the MQTT over websockets path (MqttIotHubConnection) and the HTTPS path (HttpsConnection) use this socket, and both the JDK's HTTPS client and Paho pass a real connect timeout into connect(SocketAddress, int). The previous commit on this branch is reverted; it targeted an unrelated HTTPS receive path and did not compile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../iothub/connection/ConnectionTests.java | 8 - .../device/transport/ProxiedSSLSocket.java | 34 +++- .../transport/ProxiedSSLSocketTest.java | 155 ++++++++++++++++++ 3 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java diff --git a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/connection/ConnectionTests.java b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/connection/ConnectionTests.java index 200aa848da..b1c1758786 100644 --- a/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/connection/ConnectionTests.java +++ b/iot-e2e-tests/common/src/test/java/tests/integration/com/microsoft/azure/sdk/iot/iothub/connection/ConnectionTests.java @@ -268,14 +268,6 @@ public void CanOpenConnectionWithECCCertificates() throws Exception // ECC cert generation is broken for Android. "ECDSA KeyPairGenerator is not available" assumeFalse(Tools.isAndroid()); - // On Windows agents, opening an ECC authenticated module client through the test proxy hangs until the test - // times out. The equivalent device client case and the non-ECC module client case both pass through the same - // proxy, and every case passes on Linux, so this is likely a bug in our current test proxy rather than the SDK. - // TODO to investigate - assumeFalse(!Tools.isLinux() - && testInstance.useHttpProxy - && testInstance.clientType == ClientType.MODULE_CLIENT); - testInstance.setupEccDevice(); testInstance.identity.getClient().open(true); diff --git a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java index 2c47dd0906..730019a516 100644 --- a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java +++ b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java @@ -65,18 +65,46 @@ public void connect(SocketAddress socketAddress) throws IOException @Override public void connect(SocketAddress socketAddress, int timeout) throws IOException { + InetSocketAddress inetSocketAddress = (InetSocketAddress) socketAddress; + log.debug("Sending tunnel handshake to HTTP proxy"); - doTunnelHandshake(proxySocket, ((InetSocketAddress) socketAddress).getHostName(), ((InetSocketAddress) socketAddress).getPort()); + + // The proxy's response to the CONNECT request is read from a blocking stream, so without a read timeout an + // unresponsive proxy would block this thread indefinitely rather than surfacing an error that the layers above + // can retry. Apply the caller's timeout while the tunnel is being established, then restore the previous value + // so that it does not affect traffic sent through the established tunnel. A timeout of 0 means "no timeout", + // which matches the behaviour of Socket.connect(SocketAddress, int). + int previousSoTimeout = this.proxySocket.getSoTimeout(); + this.proxySocket.setSoTimeout(timeout); + + try + { + doTunnelHandshake(this.proxySocket, inetSocketAddress.getHostName(), inetSocketAddress.getPort()); + } + catch (IOException e) + { + // Don't leak the socket if the tunnel could not be established + this.proxySocket.close(); + throw e; + } + + this.proxySocket.setSoTimeout(previousSoTimeout); + log.debug("Handshake to HTTP proxy succeeded"); //Wrap the proxy socket into the new SSLSocket so all further communication gets forwarded through the proxy - this.sslSocket = (SSLSocket) socketFactory.createSocket(proxySocket, ((InetSocketAddress) socketAddress).getHostName(), ((InetSocketAddress) socketAddress).getPort(), true); + this.sslSocket = (SSLSocket) socketFactory.createSocket(this.proxySocket, inetSocketAddress.getHostName(), inetSocketAddress.getPort(), true); } @Override public void close() throws IOException { this.proxySocket.close(); - this.sslSocket.close(); + + // May be null if the tunnel handshake never completed, so this socket was never fully connected + if (this.sslSocket != null) + { + this.sslSocket.close(); + } } /** diff --git a/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java b/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java new file mode 100644 index 0000000000..4fc6dbbd82 --- /dev/null +++ b/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +package com.microsoft.azure.sdk.iot.device.transport; + +import org.junit.Test; + +import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +/** + * Unit tests for ProxiedSSLSocket. + */ +public class ProxiedSSLSocketTest +{ + // Deliberately unresolved so that these tests never depend on DNS. ProxiedSSLSocket only reads the hostname and + // port out of the address in order to build the CONNECT request. + private static final InetSocketAddress DESTINATION = + InetSocketAddress.createUnresolved("some-iot-hub.azure-devices.net", 443); + + private static final String CONNECT_ESTABLISHED_RESPONSE = "HTTP/1.1 200 Connection established\r\n\r\n"; + + /** + * Starts a local server that accepts a single connection and then optionally writes the given response. If the + * response is null, the server accepts the connection but never answers, simulating an unresponsive proxy. + */ + private static Thread startFakeProxy(final ServerSocket serverSocket, final String response) + { + Thread thread = new Thread(() -> + { + try (Socket accepted = serverSocket.accept()) + { + if (response == null) + { + // Hold the connection open without responding until the test closes the client side + InputStream inputStream = accepted.getInputStream(); + while (inputStream.read() != -1) + { + // discard the CONNECT request and never reply + } + } + else + { + OutputStream outputStream = accepted.getOutputStream(); + outputStream.write(response.getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + + // Keep the socket open so that the client can finish wrapping it + Thread.sleep(2000); + } + } + catch (IOException | InterruptedException e) + { + // Expected once the test closes its end of the connection + } + }); + + thread.setDaemon(true); + thread.start(); + return thread; + } + + // Without a read timeout applied to the proxy socket, an unresponsive proxy blocks the connecting thread forever, + // which prevents the layers above from ever retrying the connection. The JUnit timeout below fails the test rather + // than hanging the build if that regresses. + @Test(timeout = 30000) + public void connectTimesOutWhenProxyDoesNotRespondToConnectRequest() throws Exception + { + try (ServerSocket unresponsiveProxy = new ServerSocket(0)) + { + startFakeProxy(unresponsiveProxy, null); + + try (Socket proxySocket = new Socket("127.0.0.1", unresponsiveProxy.getLocalPort())) + { + ProxiedSSLSocket proxiedSSLSocket = new ProxiedSSLSocket( + (SSLSocketFactory) SSLSocketFactory.getDefault(), proxySocket, null, null); + + try + { + proxiedSSLSocket.connect(DESTINATION, 500); + fail("Expected connect to time out instead of blocking indefinitely"); + } + catch (SocketTimeoutException expected) + { + // Expected + } + } + } + } + + // The timeout only applies while the tunnel is being established. Once the proxy has accepted the CONNECT request, + // the socket must go back to its previous timeout so that tunneled traffic isn't cut short. + @Test(timeout = 30000) + public void connectRestoresPreviousSoTimeoutAfterTunnelIsEstablished() throws Exception + { + try (ServerSocket proxy = new ServerSocket(0)) + { + startFakeProxy(proxy, CONNECT_ESTABLISHED_RESPONSE); + + try (Socket proxySocket = new Socket("127.0.0.1", proxy.getLocalPort())) + { + proxySocket.setSoTimeout(1234); + + ProxiedSSLSocket proxiedSSLSocket = new ProxiedSSLSocket( + (SSLSocketFactory) SSLSocketFactory.getDefault(), proxySocket, null, null); + + proxiedSSLSocket.connect(DESTINATION, 5000); + + assertEquals( + "Expected the socket's original read timeout to be restored once the tunnel was established", + 1234, + proxySocket.getSoTimeout()); + } + } + } + + // A proxy that rejects the CONNECT request must surface an error rather than leaving a connected socket behind. + @Test(timeout = 30000) + public void connectThrowsWhenProxyRejectsConnectRequest() throws Exception + { + try (ServerSocket proxy = new ServerSocket(0)) + { + startFakeProxy(proxy, "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"); + + try (Socket proxySocket = new Socket("127.0.0.1", proxy.getLocalPort())) + { + ProxiedSSLSocket proxiedSSLSocket = new ProxiedSSLSocket( + (SSLSocketFactory) SSLSocketFactory.getDefault(), proxySocket, null, null); + + try + { + proxiedSSLSocket.connect(DESTINATION, 5000); + fail("Expected connect to throw when the proxy rejects the CONNECT request"); + } + catch (IOException expected) + { + // Expected + } + + // The failed tunnel must not leave a connected socket behind + proxiedSSLSocket.close(); + } + } + } +} From 417c03f835c29672e3bb05deb2de856b886bd7d8 Mon Sep 17 00:00:00 2001 From: Ewerton Scaboro da Silva Date: Tue, 18 Aug 2026 11:45:06 -0700 Subject: [PATCH 4/5] Address review feedback on the proxy CONNECT timeout fix Enforce the caller's timeout as a deadline across the whole CONNECT response instead of relying on SO_TIMEOUT alone. The response is read a byte at a time and SO_TIMEOUT applies per read, so a proxy trickling out one byte per interval could keep connect() blocked indefinitely. Handle proxy responses that contain no status line. String.split discards trailing empty strings, so a response of only CRLFCRLF yields a zero length array and the status line lookup threw an ArrayIndexOutOfBoundsException that escaped the IOException cleanup path and leaked the socket. Malformed responses now raise an IOException, and connect() also cleans up after unchecked exceptions, attaching any close failure as a suppressed exception so it cannot mask the original error. Assert that connect() itself closes the proxy socket after a failed handshake rather than relying on try-with-resources, which would let the test pass even if the cleanup were removed. Both new behaviours are covered by tests that fail against the unfixed code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../device/transport/ProxiedSSLSocket.java | 97 ++++++++++++--- .../transport/ProxiedSSLSocketTest.java | 110 ++++++++++++++++++ 2 files changed, 192 insertions(+), 15 deletions(-) diff --git a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java index 730019a516..f1757aafbe 100644 --- a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java +++ b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java @@ -6,7 +6,6 @@ package com.microsoft.azure.sdk.iot.device.transport; import lombok.NonNull; -import lombok.RequiredArgsConstructor; import lombok.experimental.Delegate; import lombok.extern.slf4j.Slf4j; import org.apache.commons.codec.binary.Base64; @@ -20,10 +19,12 @@ import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketAddress; +import java.net.SocketTimeoutException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.LinkedList; import java.util.List; +import java.util.concurrent.TimeUnit; /** * Extension of an SSLSocket that sends an HTTP CONNECT packet to the proxy socket before sending the SSL handshake upstream. @@ -69,22 +70,24 @@ public void connect(SocketAddress socketAddress, int timeout) throws IOException log.debug("Sending tunnel handshake to HTTP proxy"); - // The proxy's response to the CONNECT request is read from a blocking stream, so without a read timeout an + // The proxy's response to the CONNECT request is read from a blocking stream, so without a timeout an // unresponsive proxy would block this thread indefinitely rather than surfacing an error that the layers above - // can retry. Apply the caller's timeout while the tunnel is being established, then restore the previous value - // so that it does not affect traffic sent through the established tunnel. A timeout of 0 means "no timeout", - // which matches the behaviour of Socket.connect(SocketAddress, int). + // can retry. The read timeout is applied as a deadline across the whole CONNECT response rather than per read, + // because the response is consumed a byte at a time and a proxy that trickles out one byte per interval would + // otherwise keep this call blocked forever. The previous value is restored once the tunnel is established so + // that it does not affect traffic sent through it. A timeout of 0 means "no timeout", which matches the + // behaviour of Socket.connect(SocketAddress, int). int previousSoTimeout = this.proxySocket.getSoTimeout(); - this.proxySocket.setSoTimeout(timeout); try { - doTunnelHandshake(this.proxySocket, inetSocketAddress.getHostName(), inetSocketAddress.getPort()); + doTunnelHandshake(this.proxySocket, inetSocketAddress.getHostName(), inetSocketAddress.getPort(), timeout); } - catch (IOException e) + catch (IOException | RuntimeException e) { - // Don't leak the socket if the tunnel could not be established - this.proxySocket.close(); + // Don't leak the socket if the tunnel could not be established. A malformed proxy response can surface as + // an unchecked exception rather than an IOException, so those have to be cleaned up after as well. + closeProxySocketQuietly(e); throw e; } @@ -96,6 +99,23 @@ public void connect(SocketAddress socketAddress, int timeout) throws IOException this.sslSocket = (SSLSocket) socketFactory.createSocket(this.proxySocket, inetSocketAddress.getHostName(), inetSocketAddress.getPort(), true); } + /** + * Close the proxy socket while propagating the failure that caused the tunnel to be abandoned. Any problem closing + * the socket is attached to that failure rather than replacing it. + * @param cause The failure that caused the tunnel handshake to be abandoned + */ + private void closeProxySocketQuietly(Throwable cause) + { + try + { + this.proxySocket.close(); + } + catch (IOException closeException) + { + cause.addSuppressed(closeException); + } + } + @Override public void close() throws IOException { this.proxySocket.close(); @@ -112,9 +132,10 @@ public void close() throws IOException { * @param tunnel The socket to communicate to the HTTP proxy through * @param host The destination host the proxy will forward communication to * @param port The destination port the proxy will forward communication to - * @throws IOException If unable to read or send to the HTTP proxy + * @param timeoutMillis How long to wait for the proxy's complete response, where 0 means wait indefinitely + * @throws IOException If unable to read or send to the HTTP proxy, or if the proxy did not respond in time */ - private void doTunnelHandshake(Socket tunnel, String host, int port) throws IOException + private void doTunnelHandshake(Socket tunnel, String host, int port, int timeoutMillis) throws IOException { Charset byteEncoding = StandardCharsets.UTF_8; OutputStream out = tunnel.getOutputStream(); @@ -135,18 +156,26 @@ private void doTunnelHandshake(Socket tunnel, String host, int port) throws IOEx out.flush(); //Cannot do any buffering while reading, only read what is relevant to the connect response - HttpConnectResponseReader in = new HttpConnectResponseReader(tunnel.getInputStream(), byteEncoding); + HttpConnectResponseReader in = new HttpConnectResponseReader(tunnel.getInputStream(), byteEncoding, tunnel, timeoutMillis); String connectResponse = in.readHttpConnectResponse(); String[] connectResponseLines = connectResponse.split("\r\n"); int connectResponseStart = 0; - while (connectResponseLines[connectResponseStart].isEmpty()) + while (connectResponseStart < connectResponseLines.length && connectResponseLines[connectResponseStart].isEmpty()) { connectResponseStart++; } + // A response made up entirely of blank lines has no status line to inspect. Split discards trailing empty + // strings, so a response of just "\r\n\r\n" produces an empty array rather than a set of empty lines. + if (connectResponseStart == connectResponseLines.length) + { + tunnel.close(); + throw new IOException(String.format("Unable to tunnel through %s:%d. Proxy response to CONNECT did not contain a status line", host, port)); + } + //Expects the same http version in the response as the request String firstLine = connectResponseLines[connectResponseStart]; if (!firstLine.startsWith(HTTP)) @@ -190,13 +219,49 @@ private interface ProxiedSSLSocketNonDelegatedFunctions void close(); } - @RequiredArgsConstructor static class HttpConnectResponseReader { private boolean alreadyRead = false; @NonNull private final InputStream inputStream; @NonNull private final Charset byteEncoding; + // Socket whose read timeout is narrowed as the deadline approaches. Null when no deadline is being enforced. + private final Socket socket; + private final int timeoutMillis; + private final long deadlineNanos; + + HttpConnectResponseReader(@NonNull InputStream inputStream, @NonNull Charset byteEncoding, Socket socket, int timeoutMillis) + { + this.inputStream = inputStream; + this.byteEncoding = byteEncoding; + this.socket = socket; + this.timeoutMillis = timeoutMillis; + this.deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(Math.max(timeoutMillis, 0)); + } + + /** + * Narrow the socket's read timeout to whatever is left of the caller's timeout, so that the total time spent + * reading the proxy's response is bounded even though it is read one byte at a time. + * @throws SocketTimeoutException If the deadline has already passed + * @throws IOException If the socket's timeout could not be updated + */ + private void applyRemainingTimeout() throws IOException + { + // 0 means "no timeout", matching the behaviour of Socket.connect(SocketAddress, int) + if (this.socket == null || this.timeoutMillis <= 0) + { + return; + } + + long remainingMillis = TimeUnit.NANOSECONDS.toMillis(this.deadlineNanos - System.nanoTime()); + if (remainingMillis <= 0) + { + throw new SocketTimeoutException("Timed out waiting for the HTTP proxy to respond to the CONNECT request"); + } + + this.socket.setSoTimeout((int) Math.min(remainingMillis, Integer.MAX_VALUE)); + } + String readHttpConnectResponse() throws IOException { if (alreadyRead) @@ -210,6 +275,8 @@ String readHttpConnectResponse() throws IOException //until the 4 most recently read characters were \r\n\r\n while (!isCRLF(mostRecentFourCharacters)) { + applyRemainingTimeout(); + int i = inputStream.read(); if (i == -1) { diff --git a/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java b/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java index 4fc6dbbd82..6c50fc1426 100644 --- a/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java +++ b/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java @@ -16,6 +16,7 @@ import java.nio.charset.StandardCharsets; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; /** @@ -94,6 +95,12 @@ public void connectTimesOutWhenProxyDoesNotRespondToConnectRequest() throws Exce { // Expected } + + // Asserted here rather than relying on try-with-resources, which would close the socket regardless and + // would let this test pass even if connect() stopped cleaning up after a failed handshake. + assertTrue( + "Expected connect to close the proxy socket after the handshake timed out", + proxySocket.isClosed()); } } } @@ -152,4 +159,107 @@ public void connectThrowsWhenProxyRejectsConnectRequest() throws Exception } } } + + /** + * Starts a local server that answers the CONNECT request one byte at a time, pausing between each byte. Used to + * show that the caller's timeout bounds the whole exchange rather than each individual read. + */ + private static void startTricklingProxy(final ServerSocket serverSocket, final String response, final long millisBetweenBytes) + { + Thread thread = new Thread(() -> + { + try (Socket accepted = serverSocket.accept()) + { + OutputStream outputStream = accepted.getOutputStream(); + for (byte b : response.getBytes(StandardCharsets.UTF_8)) + { + outputStream.write(b); + outputStream.flush(); + Thread.sleep(millisBetweenBytes); + } + + Thread.sleep(2000); + } + catch (IOException | InterruptedException e) + { + // Expected once the test closes its end of the connection + } + }); + + thread.setDaemon(true); + thread.start(); + } + + // A read timeout on its own only bounds each individual read, and the proxy's response is consumed one byte at a + // time. A proxy that sends a byte just before each interval expires would keep connect() blocked forever, so the + // timeout has to be enforced as a deadline across the whole response. + @Test(timeout = 30000) + public void connectTimesOutWhenProxyTricklesResponseSlowerThanTimeout() throws Exception + { + try (ServerSocket tricklingProxy = new ServerSocket(0)) + { + // Each byte arrives well within the 500ms timeout, but the full response would take far longer than it + startTricklingProxy(tricklingProxy, CONNECT_ESTABLISHED_RESPONSE, 200); + + try (Socket proxySocket = new Socket("127.0.0.1", tricklingProxy.getLocalPort())) + { + ProxiedSSLSocket proxiedSSLSocket = new ProxiedSSLSocket( + (SSLSocketFactory) SSLSocketFactory.getDefault(), proxySocket, null, null); + + long startMillis = System.currentTimeMillis(); + + try + { + proxiedSSLSocket.connect(DESTINATION, 500); + fail("Expected connect to time out rather than following the proxy's pace indefinitely"); + } + catch (SocketTimeoutException expected) + { + // Expected + } + + long elapsedMillis = System.currentTimeMillis() - startMillis; + + // Generous upper bound; without a deadline this would run until the whole response had trickled in + assertTrue( + "Expected connect to give up near its 500ms timeout, but it took " + elapsedMillis + "ms", + elapsedMillis < 5000); + + assertTrue( + "Expected connect to close the proxy socket after the handshake timed out", + proxySocket.isClosed()); + } + } + } + + // "\r\n\r\n".split("\r\n") discards trailing empty strings and yields an empty array, so a response with no status + // line used to fail with an ArrayIndexOutOfBoundsException that escaped the IOException cleanup path. + @Test(timeout = 30000) + public void connectThrowsIOExceptionAndClosesSocketWhenProxyResponseHasNoStatusLine() throws Exception + { + try (ServerSocket proxy = new ServerSocket(0)) + { + startFakeProxy(proxy, "\r\n\r\n"); + + try (Socket proxySocket = new Socket("127.0.0.1", proxy.getLocalPort())) + { + ProxiedSSLSocket proxiedSSLSocket = new ProxiedSSLSocket( + (SSLSocketFactory) SSLSocketFactory.getDefault(), proxySocket, null, null); + + try + { + proxiedSSLSocket.connect(DESTINATION, 5000); + fail("Expected connect to throw when the proxy response contains no status line"); + } + catch (IOException expected) + { + // Expected. An unchecked exception here would mean malformed responses bypass the cleanup path. + } + + assertTrue( + "Expected connect to close the proxy socket after a malformed proxy response", + proxySocket.isClosed()); + } + } + } } From d827294086372f5d42e19f378135bdf435ecb0f9 Mon Sep 17 00:00:00 2001 From: Ewerton Scaboro da Silva Date: Tue, 18 Aug 2026 15:05:12 -0700 Subject: [PATCH 5/5] Connect to the HTTP proxy within the caller's connect timeout HttpProxySocketFactory.createSocket() built the socket to the proxy with `new Socket(host, port)`, which connects eagerly. That connection attempt happened before the caller ever got the chance to pass a connect timeout to Socket.connect(SocketAddress, int), so it was bounded only by the operating system's default TCP connect timeout. An unreachable or overloaded proxy therefore blocked the calling thread with no error for the transport layer to retry on, and no log line to explain it since ProxiedSSLSocket.connect was never reached. The proxy socket is now created unconnected and connected while handling ProxiedSSLSocket.connect, where the caller's timeout applies. Connecting to the proxy and tunnelling through it share that one timeout budget so the call cannot take up to twice the requested timeout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../transport/HttpProxySocketFactory.java | 15 +++- .../device/transport/ProxiedSSLSocket.java | 89 ++++++++++++++++++- .../transport/ProxiedSSLSocketTest.java | 58 ++++++++++++ 3 files changed, 156 insertions(+), 6 deletions(-) diff --git a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/HttpProxySocketFactory.java b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/HttpProxySocketFactory.java index c05f7f3ca8..77229b1146 100644 --- a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/HttpProxySocketFactory.java +++ b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/HttpProxySocketFactory.java @@ -24,8 +24,19 @@ public class HttpProxySocketFactory extends SSLSocketFactory @Override public Socket createSocket() throws IOException { - Socket proxySocket = new Socket(proxySettings.getHostname(), proxySettings.getPort()); - return new ProxiedSSLSocket(delegate, proxySocket, proxySettings.getUsername(), proxySettings.getPassword()); + // The socket to the proxy is deliberately left unconnected here. Connecting it in this factory method would put + // the connection attempt outside of the connect timeout that the caller later passes to + // Socket.connect(SocketAddress, int), so an unreachable or overloaded proxy would block the calling thread for + // as long as the operating system's default TCP connect timeout allows. That surfaces as an unexplained hang + // with no error for the transport layer to retry on. ProxiedSSLSocket connects it instead, so that the caller's + // timeout covers connecting to the proxy as well as tunnelling through it. + return new ProxiedSSLSocket( + delegate, + new Socket(), + proxySettings.getHostname(), + proxySettings.getPort(), + proxySettings.getUsername(), + proxySettings.getPassword()); } @SuppressWarnings("unused") // Seems as if it's used in the Lombok delegate diff --git a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java index f1757aafbe..2ec7bb5961 100644 --- a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java +++ b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java @@ -41,18 +41,46 @@ class ProxiedSSLSocket extends SSLSocket @Delegate(excludes = ProxiedSSLSocketNonDelegatedFunctions.class) private SSLSocket sslSocket; + //Address of the HTTP proxy. Null when the proxy socket was handed over already connected. + private final String proxyHostname; + private final int proxyPort; + private final String proxyUsername; private final char[] proxyPassword; private static final String HTTP = "HTTP/"; private static final String HTTP_VERSION_1_1 = HTTP + "1.1"; - + /** + * Create a socket that tunnels through an HTTP proxy that has already been connected to. + * @param socketFactory The factory used to layer SSL over the tunnel once it has been established + * @param proxySocket An already connected socket to the HTTP proxy + * @param proxyUsername The username to authenticate to the proxy with, or null if the proxy needs no authentication + * @param proxyPassword The password to authenticate to the proxy with, or null if the proxy needs no authentication + */ ProxiedSSLSocket(SSLSocketFactory socketFactory, Socket proxySocket, String proxyUsername, char[] proxyPassword) + { + this(socketFactory, proxySocket, null, -1, proxyUsername, proxyPassword); + } + + /** + * Create a socket that connects to an HTTP proxy and then tunnels through it. + * @param socketFactory The factory used to layer SSL over the tunnel once it has been established + * @param proxySocket An unconnected socket that will be connected to the proxy by + * {@link #connect(SocketAddress, int)} so that the caller's connect timeout applies to it + * @param proxyHostname The hostname of the HTTP proxy + * @param proxyPort The port of the HTTP proxy + * @param proxyUsername The username to authenticate to the proxy with, or null if the proxy needs no authentication + * @param proxyPassword The password to authenticate to the proxy with, or null if the proxy needs no authentication + */ + ProxiedSSLSocket(SSLSocketFactory socketFactory, Socket proxySocket, String proxyHostname, int proxyPort, String proxyUsername, char[] proxyPassword) { this.socketFactory = socketFactory; this.proxySocket = proxySocket; + this.proxyHostname = proxyHostname; + this.proxyPort = proxyPort; + this.proxyUsername = proxyUsername; this.proxyPassword = proxyPassword; } @@ -68,8 +96,6 @@ public void connect(SocketAddress socketAddress, int timeout) throws IOException { InetSocketAddress inetSocketAddress = (InetSocketAddress) socketAddress; - log.debug("Sending tunnel handshake to HTTP proxy"); - // The proxy's response to the CONNECT request is read from a blocking stream, so without a timeout an // unresponsive proxy would block this thread indefinitely rather than surfacing an error that the layers above // can retry. The read timeout is applied as a deadline across the whole CONNECT response rather than per read, @@ -81,7 +107,13 @@ public void connect(SocketAddress socketAddress, int timeout) throws IOException try { - doTunnelHandshake(this.proxySocket, inetSocketAddress.getHostName(), inetSocketAddress.getPort(), timeout); + // Connecting to the proxy and tunnelling through it share the caller's timeout budget, so that this call + // cannot take up to twice the requested timeout. + int remainingTimeout = connectToProxy(timeout); + + log.debug("Sending tunnel handshake to HTTP proxy"); + + doTunnelHandshake(this.proxySocket, inetSocketAddress.getHostName(), inetSocketAddress.getPort(), remainingTimeout); } catch (IOException | RuntimeException e) { @@ -99,6 +131,55 @@ public void connect(SocketAddress socketAddress, int timeout) throws IOException this.sslSocket = (SSLSocket) socketFactory.createSocket(this.proxySocket, inetSocketAddress.getHostName(), inetSocketAddress.getPort(), true); } + /** + * Establish the TCP connection to the HTTP proxy if it has not been established already. + * + *

This deliberately happens here rather than when this socket is created. Creating a connected socket up front + * would put the connection attempt outside of any timeout the caller supplies to + * {@link #connect(SocketAddress, int)}, so an unreachable or overloaded proxy would block the calling thread for as + * long as the operating system's default TCP connect timeout allows, without any error for the layers above to + * retry on.

+ * + * @param timeoutMillis The total time available for connecting to the proxy and tunnelling through it, where 0 + * means wait indefinitely + * @return How much of the timeout is left for the tunnel handshake, where 0 means wait indefinitely + * @throws IOException If the proxy could not be connected to in time + */ + private int connectToProxy(int timeoutMillis) throws IOException + { + if (this.proxySocket.isConnected()) + { + //The socket was handed over already connected, so the whole timeout is available to the tunnel handshake + return timeoutMillis; + } + + if (this.proxyHostname == null) + { + throw new IOException("Cannot connect to the HTTP proxy because no proxy address was provided"); + } + + log.debug("Connecting to HTTP proxy {}:{}", this.proxyHostname, this.proxyPort); + + long startNanos = System.nanoTime(); + + this.proxySocket.connect(new InetSocketAddress(this.proxyHostname, this.proxyPort), timeoutMillis); + + // 0 means "no timeout", so there is no budget to track + if (timeoutMillis == 0) + { + return 0; + } + + long remainingMillis = timeoutMillis - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + + if (remainingMillis <= 0) + { + throw new SocketTimeoutException(String.format("Timed out after %d milliseconds while connecting to the HTTP proxy %s:%d", timeoutMillis, this.proxyHostname, this.proxyPort)); + } + + return (int) remainingMillis; + } + /** * Close the proxy socket while propagating the failure that caused the tunnel to be abandoned. Any problem closing * the socket is attached to that failure rather than replacing it. diff --git a/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java b/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java index 6c50fc1426..cbe627b30d 100644 --- a/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java +++ b/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java @@ -3,6 +3,7 @@ package com.microsoft.azure.sdk.iot.device.transport; +import com.microsoft.azure.sdk.iot.device.ProxySettings; import org.junit.Test; import javax.net.ssl.SSLSocketFactory; @@ -10,6 +11,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.InetSocketAddress; +import java.net.Proxy; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketTimeoutException; @@ -262,4 +264,60 @@ public void connectThrowsIOExceptionAndClosesSocketWhenProxyResponseHasNoStatusL } } } + + /** + * Connecting to the proxy has to happen while handling connect(), not while creating the socket, because only then + * is a connect timeout available. If the socket returned by the factory were already connected, an unreachable + * proxy would block the calling thread for as long as the operating system's default TCP connect timeout allows, + * with no error for the transport layer to retry on. + */ + @Test + public void createSocketDoesNotConnectToTheProxy() throws IOException + { + int closedPort; + try (ServerSocket temporaryServer = new ServerSocket(0)) + { + closedPort = temporaryServer.getLocalPort(); + } + + //Nothing is listening on that port now, so any attempt to connect to it fails rather than hangs + HttpProxySocketFactory socketFactory = new HttpProxySocketFactory( + (SSLSocketFactory) SSLSocketFactory.getDefault(), + new ProxySettings(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", closedPort)))); + + // Creating the socket must not attempt to reach the proxy at all + Socket socket = socketFactory.createSocket(); + + try + { + socket.connect(DESTINATION, 5000); + fail("Expected connect to fail because nothing is listening on the proxy's port"); + } + catch (IOException expected) + { + // Expected. The connection to the proxy is attempted while handling connect, where the timeout applies. + } + } + + /** + * Guards the case above from being satisfied by a socket that never connects to the proxy at all. + */ + @Test + public void connectEstablishesTheTunnelWhenTheFactoryReturnsAnUnconnectedSocket() throws IOException + { + try (ServerSocket proxy = new ServerSocket(0)) + { + startFakeProxy(proxy, CONNECT_ESTABLISHED_RESPONSE); + + HttpProxySocketFactory socketFactory = new HttpProxySocketFactory( + (SSLSocketFactory) SSLSocketFactory.getDefault(), + new ProxySettings(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", proxy.getLocalPort())))); + + Socket socket = socketFactory.createSocket(); + + socket.connect(DESTINATION, 5000); + + assertTrue("Expected the tunnel to be established through the proxy", socket.isConnected()); + } + } }