From 49aacf64ce1efc9b17fba174845a7d0d9f60df96 Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Sat, 24 Oct 2026 18:55:09 +0200 Subject: [PATCH 1/8] Raise the forwarded-channel window from 128 KB to 2 MB (vendored JSch class) Every connection through the tunnel server's port 2010 is a JSch forwarded-tcpip channel. Its local window is how much the server may send before this client acknowledges: request bodies, uploads, everything a device sends towards the site under test. JSch hard-codes it at 128 KB (16 KB packets), which caps each connection at 128 KB per round trip. JSch has no setting for it. The constants are private, and Channel#setLocalWindowSizeMax only applies before a channel is connected, while Session creates and confirms these when the server opens them. So ChannelForwardedTCPIP is vendored from 2.28.8 with two constants changed (2 MB window, 32 KB packets). Project classes come first on the classpath and in the shaded jar, and JSch has no multi-release copy of this class. JschForwardedWindowTest checks that the vendored class is the one loaded, and fails when JSch is upgraded, so it is re-copied rather than silently replacing a newer one. Measured end to end against a real tunnel VM (uploads through 2009 / 2011): - 20 ms: 3.1 / 3.1 MB/s -> 12.7 / 30.2 MB/s - 50 ms: 1.3 / 1.3 MB/s -> 5.1 / 14.3 MB/s (13 MB/s through 2009 with BBR on the tunnel VM) Downloads, latency, head-of-line blocking, the burst test and CPU are unchanged. --- .../jcraft/jsch/ChannelForwardedTCPIP.java | 351 ++++++++++++++++++ .../jcraft/jsch/JschForwardedWindowTest.java | 30 ++ 2 files changed, 381 insertions(+) create mode 100644 src/main/java/com/jcraft/jsch/ChannelForwardedTCPIP.java create mode 100644 src/test/java/com/jcraft/jsch/JschForwardedWindowTest.java diff --git a/src/main/java/com/jcraft/jsch/ChannelForwardedTCPIP.java b/src/main/java/com/jcraft/jsch/ChannelForwardedTCPIP.java new file mode 100644 index 0000000..b2dccec --- /dev/null +++ b/src/main/java/com/jcraft/jsch/ChannelForwardedTCPIP.java @@ -0,0 +1,351 @@ +/* + * Copyright (c) 2002-2018 ymnk, JCraft,Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted + * provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions + * and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of + * conditions and the following disclaimer in the documentation and/or other materials provided with + * the distribution. + * + * 3. The names of the authors may not be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL JCRAFT, INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/* + * VENDORED from com.github.mwiede:jsch 2.28.8 (same license as above), with ONE change: the local + * window of forwarded-tcpip channels is 2 MB instead of 128 KB, and packets 32 KB instead of 16 KB. + * + * Every connection through the tunnel server's port 2010 is one of these channels, and its local + * window is how much the server may send before this client acknowledges it: request bodies, + * uploads, everything a device sends towards the site under test. At 128 KB that capped each + * connection at 128 KB per round trip -- 3 MB/s at 20 ms to the tunnel server, 1.3 MB/s at 50 ms. + * With 2 MB, measured end to end against a real tunnel VM: 12.7-30 MB/s at 20 ms, 5-14 MB/s at + * 50 ms; downloads, latency and CPU unchanged. JSch has no setting for it: the constants are + * private, and Channel#setLocalWindowSizeMax (public since 2.28.8) only works before a channel is + * connected, while these are created and confirmed by Session when the server opens them. + * + * The project's classes come first on the classpath and in the shaded jar, so this replaces + * JSch's. JschForwardedWindowTest fails when the JSch version changes: copy that version's + * ChannelForwardedTCPIP over this file and re-apply the two constants (or drop this file once JSch + * makes the window configurable). + */ +package com.jcraft.jsch; + +import java.io.PipedOutputStream; +import java.net.Socket; +import java.util.Vector; + +public class ChannelForwardedTCPIP extends Channel { + + private static Vector pool = new Vector<>(); + + static final int LOCAL_WINDOW_SIZE_MAX = 0x200000; // TestingBot: was 0x20000 + static final int LOCAL_MAXIMUM_PACKET_SIZE = 0x8000; // TestingBot: was 0x4000 + + private static final int TIMEOUT = 10 * 1000; + + private Socket socket = null; + private ForwardedTCPIPDaemon daemon = null; + private Config config = null; + + ChannelForwardedTCPIP() { + super(); + lwsize_max = LOCAL_WINDOW_SIZE_MAX; + lwsize = LOCAL_WINDOW_SIZE_MAX; + lmpsize = LOCAL_MAXIMUM_PACKET_SIZE; + io = new IO(); + connected = true; + } + + @Override + public void run() { + try { + if (config instanceof ConfigDaemon) { + ConfigDaemon _config = (ConfigDaemon) config; + Class c = + Class.forName(_config.target).asSubclass(ForwardedTCPIPDaemon.class); + daemon = c.getDeclaredConstructor().newInstance(); + + PipedOutputStream out = new PipedOutputStream(); + io.setInputStream(new PassiveInputStream(out, 32 * 1024), false); + + daemon.setChannel(this, getInputStream(), out); + daemon.setArg(_config.arg); + getSession().getThreadFactory().newThread(daemon).start(); + } else { + ConfigLHost _config = (ConfigLHost) config; + socket = + (_config.factory == null) ? Util.createSocket(_config.target, _config.lport, TIMEOUT) + : _config.factory.createSocket(_config.target, _config.lport); + socket.setTcpNoDelay(true); + io.setInputStream(socket.getInputStream()); + io.setOutputStream(socket.getOutputStream()); + } + sendOpenConfirmation(); + } catch (Exception e) { + sendOpenFailure(SSH_OPEN_ADMINISTRATIVELY_PROHIBITED); + close = true; + disconnect(); + return; + } + + thread = Thread.currentThread(); + Buffer buf = new Buffer(rmpsize); + Packet packet = new Packet(buf); + int i = 0; + try { + Session _session = getSession(); + while (thread != null && io != null && io.in != null) { + i = io.in.read(buf.buffer, 14, buf.buffer.length - 14 - _session.getBufferMargin()); + if (i <= 0) { + eof(); + break; + } + packet.reset(); + buf.putByte((byte) Session.SSH_MSG_CHANNEL_DATA); + buf.putInt(recipient); + buf.putInt(i); + buf.skip(i); + synchronized (this) { + if (close) + break; + _session.write(packet, this, i); + } + } + } catch (Exception e) { + // System.err.println(e); + } + // thread=null; + // eof(); + disconnect(); + } + + @Override + void getData(Buffer buf) { + setRecipient(buf.getInt()); + setRemoteWindowSize(buf.getUInt()); + setRemotePacketSize(buf.getInt()); + byte[] addr = buf.getString(); + int port = buf.getInt(); + byte[] orgaddr = buf.getString(); + int orgport = buf.getInt(); + + /* + * System.err.println("addr: "+Util.byte2str(addr)); System.err.println("port: "+port); + * System.err.println("orgaddr: "+Util.byte2str(orgaddr)); + * System.err.println("orgport: "+orgport); + */ + + Session _session = null; + try { + _session = getSession(); + } catch (JSchException e) { + // session has been already down. + } + + this.config = getPort(_session, Util.byte2str(addr), port); + if (this.config == null) + this.config = getPort(_session, null, port); + + if (this.config == null) { + if (_session.getLogger().isEnabled(Logger.ERROR)) { + _session.getLogger().log(Logger.ERROR, + "ChannelForwardedTCPIP: " + Util.byte2str(addr) + ":" + port + " is not registered."); + } + } + } + + private static Config getPort(Session session, String address_to_bind, int rport) { + synchronized (pool) { + for (int i = 0; i < pool.size(); i++) { + Config bar = pool.elementAt(i); + if (bar.session != session) + continue; + if (bar.rport != rport) { + if (bar.rport != 0 || bar.allocated_rport != rport) + continue; + } + if (address_to_bind != null && !bar.address_to_bind.equals(address_to_bind)) + continue; + return bar; + } + return null; + } + } + + static String[] getPortForwarding(Session session) { + Vector foo = new Vector<>(); + synchronized (pool) { + for (int i = 0; i < pool.size(); i++) { + Config config = pool.elementAt(i); + if (config.session == session) { + if (config instanceof ConfigDaemon) + foo.addElement(config.allocated_rport + ":" + config.target + ":"); + else + foo.addElement( + config.allocated_rport + ":" + config.target + ":" + ((ConfigLHost) config).lport); + } + } + } + String[] bar = new String[foo.size()]; + for (int i = 0; i < foo.size(); i++) { + bar[i] = foo.elementAt(i); + } + return bar; + } + + static String normalize(String address) { + if (address == null) { + return "localhost"; + } else if (address.length() == 0 || address.equals("*")) { + return ""; + } else { + return address; + } + } + + static void addPort(Session session, String _address_to_bind, int port, int allocated_port, + String target, int lport, SocketFactory factory) throws JSchException { + String address_to_bind = normalize(_address_to_bind); + synchronized (pool) { + if (getPort(session, address_to_bind, port) != null) { + throw new JSchException("PortForwardingR: remote port " + port + " is already registered."); + } + ConfigLHost config = new ConfigLHost(); + config.session = session; + config.rport = port; + config.allocated_rport = allocated_port; + config.target = target; + config.lport = lport; + config.address_to_bind = address_to_bind; + config.factory = factory; + pool.addElement(config); + } + } + + static void addPort(Session session, String _address_to_bind, int port, int allocated_port, + String daemon, Object[] arg) throws JSchException { + String address_to_bind = normalize(_address_to_bind); + synchronized (pool) { + if (getPort(session, address_to_bind, port) != null) { + throw new JSchException("PortForwardingR: remote port " + port + " is already registered."); + } + ConfigDaemon config = new ConfigDaemon(); + config.session = session; + config.rport = port; + config.allocated_rport = port; + config.target = daemon; + config.arg = arg; + config.address_to_bind = address_to_bind; + pool.addElement(config); + } + } + + static void delPort(ChannelForwardedTCPIP c) { + Session _session = null; + try { + _session = c.getSession(); + } catch (JSchException e) { + // session has been already down. + } + if (_session != null && c.config != null) + delPort(_session, c.config.rport); + } + + static void delPort(Session session, int rport) { + delPort(session, null, rport); + } + + static void delPort(Session session, String address_to_bind, int rport) { + synchronized (pool) { + Config foo = getPort(session, normalize(address_to_bind), rport); + if (foo == null) + foo = getPort(session, null, rport); + if (foo == null) + return; + pool.removeElement(foo); + if (address_to_bind == null) { + address_to_bind = foo.address_to_bind; + } + if (address_to_bind == null) { + address_to_bind = "0.0.0.0"; + } + } + + Buffer buf = new Buffer(200); // ?? + Packet packet = new Packet(buf); + + try { + // byte SSH_MSG_GLOBAL_REQUEST 80 + // string "cancel-tcpip-forward" + // boolean want_reply + // string address_to_bind (e.g. "127.0.0.1") + // uint32 port number to bind + packet.reset(); + buf.putByte((byte) 80 /* SSH_MSG_GLOBAL_REQUEST */); + buf.putString(Util.str2byte("cancel-tcpip-forward")); + buf.putByte((byte) 0); + buf.putString(Util.str2byte(address_to_bind)); + buf.putInt(rport); + session.write(packet); + } catch (Exception e) { + // throw new JSchException(e.toString(), e); + } + } + + static void delPort(Session session) { + int[] rport = null; + int count = 0; + synchronized (pool) { + rport = new int[pool.size()]; + for (int i = 0; i < pool.size(); i++) { + Config config = pool.elementAt(i); + if (config.session == session) { + rport[count++] = config.rport; // ((Integer)bar[1]).intValue(); + } + } + } + for (int i = 0; i < count; i++) { + delPort(session, rport[i]); + } + } + + public int getRemotePort() { + return (config != null ? config.rport : 0); + } + + private void setSocketFactory(SocketFactory factory) { + if (config != null && (config instanceof ConfigLHost)) + ((ConfigLHost) config).factory = factory; + } + + abstract static class Config { + Session session; + int rport; + int allocated_rport; + String address_to_bind; + String target; + } + + static class ConfigDaemon extends Config { + Object[] arg; + } + + static class ConfigLHost extends Config { + int lport; + SocketFactory factory; + } +} diff --git a/src/test/java/com/jcraft/jsch/JschForwardedWindowTest.java b/src/test/java/com/jcraft/jsch/JschForwardedWindowTest.java new file mode 100644 index 0000000..e47d90e --- /dev/null +++ b/src/test/java/com/jcraft/jsch/JschForwardedWindowTest.java @@ -0,0 +1,30 @@ +package com.jcraft.jsch; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Guards the vendored ChannelForwardedTCPIP (src/main/java/com/jcraft/jsch): it must be the class + * that is loaded, with the larger window, and it must come from the JSch version it was copied from. + */ +class JschForwardedWindowTest { + + /** The JSch release ChannelForwardedTCPIP.java was copied from. */ + private static final String VENDORED_FROM = "2.28.8"; + + @Test + void theVendoredClassIsTheOneLoaded() { + ChannelForwardedTCPIP channel = new ChannelForwardedTCPIP(); + + assertThat(channel.getLocalWindowSizeMax()).isEqualTo(0x200000); + assertThat(channel.lmpsize).isEqualTo(0x8000); + } + + @Test + void jschIsStillTheVersionItWasCopiedFrom() { + // A failure here is a JSch upgrade: copy the new version's ChannelForwardedTCPIP over the + // vendored one, re-apply the two constants, and update VENDORED_FROM. + assertThat(JSch.VERSION).isEqualTo(VENDORED_FROM); + } +} From 705d49f194ec81dbb53990994df0f814514fd156 Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Sun, 25 Oct 2026 14:26:34 +0100 Subject: [PATCH 2/8] Read a probe answer that arrives together with the channel close probeReverseForward checked in.available() and then channel.isClosed(). The probe proxy closes as soon as it has answered, so the answer and the close can both land between those two calls; the loop then broke out with the answer sitting unread in the buffer and reported the forward as dead. That is how aReverseForwardTheServerRefusedIsRequestedAgainByTheMonitor failed on the Java 25 leg in 94 ms, well short of the 5 s timeout. --- src/main/java/ssh/SSHTunnel.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/ssh/SSHTunnel.java b/src/main/java/ssh/SSHTunnel.java index 8d9e86c..5510e09 100644 --- a/src/main/java/ssh/SSHTunnel.java +++ b/src/main/java/ssh/SSHTunnel.java @@ -419,6 +419,12 @@ boolean probeReverseForward(int timeoutMs) { return true; } } else if (channel.isClosed()) { + // The answer and the close arrive together (the proxy closes as soon as it + // has answered), so both can land between available() and isClosed(). + // Buffered data survives the close: read it before giving up. + if (in.available() > 0) { + continue; + } break; } else { Thread.sleep(20); From d2d5f317e7d4bfdedef34f96c06a9b57b8c6552a Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Sun, 25 Oct 2026 20:05:24 +0100 Subject: [PATCH 3/8] TEMP: diagnose probe failure on CI (to be reverted) --- src/main/java/ssh/SSHTunnel.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/ssh/SSHTunnel.java b/src/main/java/ssh/SSHTunnel.java index 5510e09..1fd3467 100644 --- a/src/main/java/ssh/SSHTunnel.java +++ b/src/main/java/ssh/SSHTunnel.java @@ -425,6 +425,7 @@ boolean probeReverseForward(int timeoutMs) { if (in.available() > 0) { continue; } + System.err.println("PROBEDBG closed, got=[" + answer + "] exit=" + channel.getExitStatus()); break; } else { Thread.sleep(20); @@ -435,6 +436,7 @@ boolean probeReverseForward(int timeoutMs) { Thread.currentThread().interrupt(); return false; } catch (Exception failed) { + System.err.println("PROBEDBG exception " + failed); failed.printStackTrace(); // The channel could not be opened: nothing listens on 2010, or the server does not // permit the probe. Which of the two is decided by the caller. return false; From 20e093c41a1d0be3839b4304ca42d8cd255ab904 Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Sun, 25 Oct 2026 20:31:20 +0100 Subject: [PATCH 4/8] TEMP: repeat the flaky test on CI (to be reverted) --- src/main/java/ssh/SSHTunnel.java | 2 +- src/test/java/ssh/SSHTunnelEmbeddedServerTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/ssh/SSHTunnel.java b/src/main/java/ssh/SSHTunnel.java index 1fd3467..c5ebaf0 100644 --- a/src/main/java/ssh/SSHTunnel.java +++ b/src/main/java/ssh/SSHTunnel.java @@ -436,7 +436,7 @@ boolean probeReverseForward(int timeoutMs) { Thread.currentThread().interrupt(); return false; } catch (Exception failed) { - System.err.println("PROBEDBG exception " + failed); failed.printStackTrace(); + System.err.println("PROBEDBG exception " + failed + " port=" + remotePort); failed.printStackTrace(); // The channel could not be opened: nothing listens on 2010, or the server does not // permit the probe. Which of the two is decided by the caller. return false; diff --git a/src/test/java/ssh/SSHTunnelEmbeddedServerTest.java b/src/test/java/ssh/SSHTunnelEmbeddedServerTest.java index 943e56b..1a81e9a 100644 --- a/src/test/java/ssh/SSHTunnelEmbeddedServerTest.java +++ b/src/test/java/ssh/SSHTunnelEmbeddedServerTest.java @@ -326,7 +326,7 @@ void theProbeFailsWhenTheServerHasNoReverseForwardForUs() throws Exception { assertThat(tunnel.probeReverseForward(2_000)).isFalse(); } - @Test + @org.junit.jupiter.api.RepeatedTest(200) void aReverseForwardTheServerRefusedIsRequestedAgainByTheMonitor() throws Exception { // The production failure: an older session of the same account still holds 2010 on the // tunnel server, so the forward is refused. It used to be asked for exactly once. From 284864ca21aba9ad4e1b6d9457f1a48496763739 Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Sun, 25 Oct 2026 20:32:24 +0100 Subject: [PATCH 5/8] TEMP: fix diagnostic compile error --- src/main/java/ssh/SSHTunnel.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/ssh/SSHTunnel.java b/src/main/java/ssh/SSHTunnel.java index c5ebaf0..1fd3467 100644 --- a/src/main/java/ssh/SSHTunnel.java +++ b/src/main/java/ssh/SSHTunnel.java @@ -436,7 +436,7 @@ boolean probeReverseForward(int timeoutMs) { Thread.currentThread().interrupt(); return false; } catch (Exception failed) { - System.err.println("PROBEDBG exception " + failed + " port=" + remotePort); failed.printStackTrace(); + System.err.println("PROBEDBG exception " + failed); failed.printStackTrace(); // The channel could not be opened: nothing listens on 2010, or the server does not // permit the probe. Which of the two is decided by the caller. return false; From b18f6ff75490150b39e4cf0fc19beed7202b48eb Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Sun, 25 Oct 2026 20:56:58 +0100 Subject: [PATCH 6/8] TEMP: drop the repeat, keep diagnostics --- src/test/java/ssh/SSHTunnelEmbeddedServerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/ssh/SSHTunnelEmbeddedServerTest.java b/src/test/java/ssh/SSHTunnelEmbeddedServerTest.java index 1a81e9a..943e56b 100644 --- a/src/test/java/ssh/SSHTunnelEmbeddedServerTest.java +++ b/src/test/java/ssh/SSHTunnelEmbeddedServerTest.java @@ -326,7 +326,7 @@ void theProbeFailsWhenTheServerHasNoReverseForwardForUs() throws Exception { assertThat(tunnel.probeReverseForward(2_000)).isFalse(); } - @org.junit.jupiter.api.RepeatedTest(200) + @Test void aReverseForwardTheServerRefusedIsRequestedAgainByTheMonitor() throws Exception { // The production failure: an older session of the same account still holds 2010 on the // tunnel server, so the forward is refused. It used to be asked for exactly once. From 8ae296e3b9aefff11bf321c7d0b2b4c838886b29 Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Sun, 25 Oct 2026 21:08:52 +0100 Subject: [PATCH 7/8] Revert the temporary probe diagnostics and 705d49f 705d49f was written against a misdiagnosis: the probe was not losing a buffered answer, its thread had been left interrupted. --- src/main/java/ssh/SSHTunnel.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main/java/ssh/SSHTunnel.java b/src/main/java/ssh/SSHTunnel.java index 1fd3467..8d9e86c 100644 --- a/src/main/java/ssh/SSHTunnel.java +++ b/src/main/java/ssh/SSHTunnel.java @@ -419,13 +419,6 @@ boolean probeReverseForward(int timeoutMs) { return true; } } else if (channel.isClosed()) { - // The answer and the close arrive together (the proxy closes as soon as it - // has answered), so both can land between available() and isClosed(). - // Buffered data survives the close: read it before giving up. - if (in.available() > 0) { - continue; - } - System.err.println("PROBEDBG closed, got=[" + answer + "] exit=" + channel.getExitStatus()); break; } else { Thread.sleep(20); @@ -436,7 +429,6 @@ boolean probeReverseForward(int timeoutMs) { Thread.currentThread().interrupt(); return false; } catch (Exception failed) { - System.err.println("PROBEDBG exception " + failed); failed.printStackTrace(); // The channel could not be opened: nothing listens on 2010, or the server does not // permit the probe. Which of the two is decided by the caller. return false; From 637a823b2847e7b1bdaf2cd315d1bcc8e59e4bf8 Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Sun, 25 Oct 2026 21:16:18 +0100 Subject: [PATCH 8/8] Register reverse forwards on a thread of their own JSch wakes the thread waiting for the server's tcpip-forward reply by interrupting it, after setting the reply (Session.java, case SSH_MSG_REQUEST_SUCCESS/FAILURE). On loopback the reply is quick enough that the caller sees it, returns from setPortForwardingR, and receives the interrupt afterwards. The caller was left interrupted and its next blocking call failed: - SSHTunnelEmbeddedServerTest.aReverseForwardTheServerRefusedIsRequestedAgainByTheMonitor: the probe after the re-registration returned false in ~60 ms - AppEmbeddedLifecycleTest: boot() failed starting Jetty with an InterruptedException (also seen on main, run 40093219005) Both call sites now go through a helper that runs the request on a short-lived thread, which absorbs the interrupt. The wait stays uninterruptible and bounded, as JSch's own is. --- src/main/java/ssh/SSHTunnel.java | 50 ++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/main/java/ssh/SSHTunnel.java b/src/main/java/ssh/SSHTunnel.java index 01722a7..648c58e 100644 --- a/src/main/java/ssh/SSHTunnel.java +++ b/src/main/java/ssh/SSHTunnel.java @@ -21,6 +21,8 @@ import java.util.Timer; import java.util.TimerTask; import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; @@ -476,7 +478,7 @@ private int bindMuxPort() { for (int i = 0; i < MUX_PORT_COUNT; i++) { int port = muxFirstPort + (slot + i) % MUX_PORT_COUNT; try { - session.setPortForwardingR(port, REVERSE_FORWARD_HOST, app.getJettyPort()); + setPortForwardingR(port); return port; } catch (JSchException taken) { Logger.getLogger(SSHTunnel.class.getName()).log(Level.FINE, @@ -583,9 +585,53 @@ private void startExtra() { } } + /** + * Registers a reverse forward on a thread of its own. + * + *

JSch wakes the thread waiting for the server's reply to {@code tcpip-forward} by + * interrupting it, and sets the reply first. When the reply is quick -- always, on loopback -- + * the caller can see it, return, and receive the interrupt afterwards. The caller was left + * interrupted, so its next blocking call failed: the probe that follows a re-registration + * returned false at once, and {@code boot()} failed starting Jetty. A thread that ends here + * absorbs the interrupt instead. + * + *

The wait is uninterruptible, as JSch's own is, and bounded by JSch's ten-second reply + * timeout; an interrupt that arrives meanwhile is restored rather than failing the forward. + */ + private void setPortForwardingR(int port) throws JSchException { + Session current = session; + FutureTask request = new FutureTask<>(() -> { + current.setPortForwardingR(port, REVERSE_FORWARD_HOST, app.getJettyPort()); + return null; + }); + Thread requester = new Thread(request, "tcpip-forward-" + connectionId); + requester.setDaemon(true); + requester.start(); + boolean interrupted = false; + try { + while (true) { + try { + request.get(); + return; + } catch (InterruptedException again) { + interrupted = true; + } + } + } catch (ExecutionException failed) { + if (failed.getCause() instanceof JSchException jschFailure) { + throw jschFailure; + } + throw new JSchException(String.valueOf(failed.getCause()), failed.getCause()); + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } + private boolean establishLegacyReverseForward() { try { - session.setPortForwardingR(remoteProxyPort, REVERSE_FORWARD_HOST, app.getJettyPort()); + setPortForwardingR(remoteProxyPort); boundRemotePort = remoteProxyPort; return true; } catch (JSchException ex) {