Skip to content

Commit f33bcc4

Browse files
jpstotzhierynomus
andauthored
Make ChannelInputStream respect connection timeout (#1034)
* Make ChannelInputStream respect connection timeout * If wait ended with a timeout throw exception * Do not throw timeout exception in eof state * Change synchronization from synchronized/wait/notifyAll to ReentrantLock and Condition for safely identify timeouts * Source channel read timeout from Config instead of connection timeout Introduce Config#getChannelReadTimeoutMs and getChannelErrorReadTimeoutMs (both defaulting to 0) so stream read timeouts are configured independently of the connection-level timeout. Additional fixes on top of the original PR: - Restore the 3-arg ChannelInputStream constructor for binary compatibility - Fix notifyError idempotency (restores if (!eof) guard) - Move eof check in receive() inside the lock for consistent locking discipline - Throw SocketTimeoutException on timeout instead of IOException * Add tests --------- Co-authored-by: Jeroen van Erp <jeroen@hierynomus.com>
1 parent a157c0e commit f33bcc4

6 files changed

Lines changed: 214 additions & 21 deletions

File tree

src/main/java/net/schmizz/sshj/Config.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,12 @@ public interface Config {
204204
int getMaxCircularBufferSize();
205205

206206
void setMaxCircularBufferSize(int maxCircularBufferSize);
207+
208+
int getChannelReadTimeoutMs();
209+
210+
void setChannelReadTimeoutMs(int channelReadTimeoutMs);
211+
212+
int getChannelErrorReadTimeoutMs();
213+
214+
void setChannelErrorReadTimeoutMs(int channelErrorReadTimeoutMs);
207215
}

src/main/java/net/schmizz/sshj/ConfigImpl.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ public class ConfigImpl
5151
private boolean verifyHostKeyCertificates = true;
5252
// HF-982: default to 16MB buffers.
5353
private int maxCircularBufferSize = 16 * 1024 * 1024;
54+
private int channelReadTimeoutMs = 0;
55+
private int channelErrorReadTimeoutMs = 0;
5456

5557
@Override
5658
public List<Factory.Named<Cipher>> getCipherFactories() {
@@ -187,6 +189,26 @@ public void setMaxCircularBufferSize(int maxCircularBufferSize) {
187189
this.maxCircularBufferSize = maxCircularBufferSize;
188190
}
189191

192+
@Override
193+
public int getChannelReadTimeoutMs() {
194+
return channelReadTimeoutMs;
195+
}
196+
197+
@Override
198+
public void setChannelReadTimeoutMs(int channelReadTimeoutMs) {
199+
this.channelReadTimeoutMs = channelReadTimeoutMs;
200+
}
201+
202+
@Override
203+
public int getChannelErrorReadTimeoutMs() {
204+
return channelErrorReadTimeoutMs;
205+
}
206+
207+
@Override
208+
public void setChannelErrorReadTimeoutMs(int channelErrorReadTimeoutMs) {
209+
this.channelErrorReadTimeoutMs = channelErrorReadTimeoutMs;
210+
}
211+
190212
@Override
191213
public void setLoggerFactory(LoggerFactory loggerFactory) {
192214
this.loggerFactory = loggerFactory;

src/main/java/net/schmizz/sshj/connection/channel/AbstractChannel.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ protected AbstractChannel(Connection conn, String type, Charset remoteCharset) {
9595
id = conn.nextID();
9696

9797
lwin = new Window.Local(conn.getWindowSize(), conn.getMaxPacketSize(), loggerFactory);
98-
in = new ChannelInputStream(this, trans, lwin);
98+
in = new ChannelInputStream(this, trans, lwin, trans.getConfig().getChannelReadTimeoutMs());
9999

100100
openEvent = new Event<ConnectionException>("chan#" + id + " / " + "open", ConnectionException.chainer, openCloseLock, loggerFactory);
101101
closeEvent = new Event<ConnectionException>("chan#" + id + " / " + "close", ConnectionException.chainer, openCloseLock, loggerFactory);

src/main/java/net/schmizz/sshj/connection/channel/ChannelInputStream.java

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
import java.io.IOException;
2525
import java.io.InputStream;
2626
import java.io.InterruptedIOException;
27+
import java.net.SocketTimeoutException;
28+
import java.util.concurrent.TimeUnit;
29+
import java.util.concurrent.locks.Condition;
30+
import java.util.concurrent.locks.ReentrantLock;
2731

2832
/**
2933
* {@link InputStream} for channels. Can {@link #receive(byte[], int, int) receive} data into its buffer for serving to
@@ -38,25 +42,37 @@ public final class ChannelInputStream
3842
private final Channel chan;
3943
private final Transport trans;
4044
private final Window.Local win;
45+
private final int timeoutMs;
4146
private final CircularBuffer.PlainCircularBuffer buf;
47+
private final ReentrantLock lock = new ReentrantLock();
48+
private final Condition dataArrived = lock.newCondition();
4249
private final byte[] b = new byte[1];
4350

4451
private boolean eof;
4552
private SSHException error;
4653

54+
/** Creates a non-timeout channel input stream. Reads will block indefinitely until data arrives or EOF. */
4755
public ChannelInputStream(Channel chan, Transport trans, Window.Local win) {
56+
this(chan, trans, win, 0);
57+
}
58+
59+
public ChannelInputStream(Channel chan, Transport trans, Window.Local win, int timeoutMs) {
4860
this.chan = chan;
4961
this.log = chan.getLoggerFactory().getLogger(getClass());
5062
this.trans = trans;
5163
this.win = win;
64+
this.timeoutMs = timeoutMs;
5265
this.buf = new CircularBuffer.PlainCircularBuffer(
5366
chan.getLocalMaxPacketSize(), trans.getConfig().getMaxCircularBufferSize());
5467
}
5568

5669
@Override
5770
public int available() {
58-
synchronized (buf) {
71+
lock.lock();
72+
try {
5973
return buf.available();
74+
} finally {
75+
lock.unlock();
6076
}
6177
}
6278

@@ -66,18 +82,29 @@ public void close() {
6682
}
6783

6884
public void eof() {
69-
synchronized (buf) {
85+
lock.lock();
86+
try {
7087
if (!eof) {
7188
eof = true;
72-
buf.notifyAll();
89+
dataArrived.signalAll();
7390
}
91+
} finally {
92+
lock.unlock();
7493
}
7594
}
7695

7796
@Override
78-
public synchronized void notifyError(SSHException error) {
79-
this.error = error;
80-
eof();
97+
public void notifyError(SSHException error) {
98+
lock.lock();
99+
try {
100+
if (!eof) {
101+
this.error = error;
102+
eof = true;
103+
dataArrived.signalAll();
104+
}
105+
} finally {
106+
lock.unlock();
107+
}
81108
}
82109

83110
@Override
@@ -91,11 +118,9 @@ public int read()
91118
@Override
92119
public int read(byte[] b, int off, int len)
93120
throws IOException {
94-
synchronized (buf) {
95-
for (; ; ) {
96-
if (buf.available() > 0) {
97-
break;
98-
}
121+
lock.lock();
122+
try {
123+
while (buf.available() == 0) {
99124
if (eof) {
100125
if (error != null) {
101126
throw error;
@@ -104,39 +129,51 @@ public int read(byte[] b, int off, int len)
104129
}
105130
}
106131
try {
107-
buf.wait();
132+
if (timeoutMs > 0) {
133+
if (!dataArrived.await(timeoutMs, TimeUnit.MILLISECONDS)) {
134+
throw new SocketTimeoutException("Timeout of " + timeoutMs + "ms while waiting for data");
135+
}
136+
} else {
137+
dataArrived.await();
138+
}
108139
} catch (InterruptedException e) {
109140
Thread.currentThread().interrupt();
110141
throw (IOException) new InterruptedIOException().initCause(e);
111142
}
112143
}
113-
if (len > buf.available()) {
114-
len = buf.available();
144+
int available = buf.available();
145+
if (len > available) {
146+
len = available;
115147
}
116148
buf.readRawBytes(b, off, len);
117149

118150
if (!chan.getAutoExpand()) {
119151
checkWindow();
120152
}
153+
} finally {
154+
lock.unlock();
121155
}
122156

123157
return len;
124158
}
125159

126160
public void receive(byte[] data, int offset, int len) throws SSHException {
127-
if (eof) {
128-
throw new ConnectionException("Getting data on EOF'ed stream");
129-
}
130-
synchronized (buf) {
161+
lock.lock();
162+
try {
163+
if (eof) {
164+
throw new ConnectionException("Getting data on EOF'ed stream");
165+
}
131166
buf.putRawBytes(data, offset, len);
132-
buf.notifyAll();
167+
dataArrived.signalAll();
133168
// Potential fix for #203 (window consumed below 0).
134169
// This seems to be a race condition if we receive more data, while we're already sending a SSH_MSG_CHANNEL_WINDOW_ADJUST
135170
// And the window has not expanded yet.
136171
win.consume(len);
137172
if (chan.getAutoExpand()) {
138173
checkWindow();
139174
}
175+
} finally {
176+
lock.unlock();
140177
}
141178
}
142179

src/main/java/net/schmizz/sshj/connection/channel/direct/SessionChannel.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public class SessionChannel
3232
extends AbstractDirectChannel
3333
implements Session, Session.Command, Session.Shell, Session.Subsystem {
3434

35-
private final ChannelInputStream err = new ChannelInputStream(this, trans, lwin);
35+
private final ChannelInputStream err = new ChannelInputStream(this, trans, lwin, trans.getConfig().getChannelErrorReadTimeoutMs());
3636

3737
private volatile Integer exitStatus;
3838

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* Copyright (C)2009 - SSHJ Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package net.schmizz.sshj.connection.channel;
17+
18+
import net.schmizz.sshj.Config;
19+
import net.schmizz.sshj.common.LoggerFactory;
20+
import net.schmizz.sshj.common.SSHException;
21+
import net.schmizz.sshj.transport.Transport;
22+
import org.junit.jupiter.api.AfterEach;
23+
import org.junit.jupiter.api.BeforeEach;
24+
import org.junit.jupiter.api.Test;
25+
26+
import java.net.SocketTimeoutException;
27+
import java.util.concurrent.Executors;
28+
import java.util.concurrent.ScheduledExecutorService;
29+
import java.util.concurrent.TimeUnit;
30+
31+
import static org.junit.jupiter.api.Assertions.*;
32+
import static org.mockito.Mockito.*;
33+
34+
public class ChannelInputStreamTest {
35+
36+
private Channel chan;
37+
private Window.Local win;
38+
private ScheduledExecutorService scheduler;
39+
40+
@BeforeEach
41+
void setUp() {
42+
Config config = mock(Config.class);
43+
when(config.getMaxCircularBufferSize()).thenReturn(16 * 1024 * 1024);
44+
45+
Transport trans = mock(Transport.class);
46+
when(trans.getConfig()).thenReturn(config);
47+
48+
chan = mock(Channel.class);
49+
when(chan.getLoggerFactory()).thenReturn(LoggerFactory.DEFAULT);
50+
when(chan.getLocalMaxPacketSize()).thenReturn(32768);
51+
when(chan.getAutoExpand()).thenReturn(false);
52+
53+
win = new Window.Local(2097152, 32768, LoggerFactory.DEFAULT);
54+
scheduler = Executors.newSingleThreadScheduledExecutor();
55+
}
56+
57+
@AfterEach
58+
void tearDown() {
59+
scheduler.shutdownNow();
60+
}
61+
62+
@Test
63+
void timeoutFiresWhenNoDataArrives() {
64+
ChannelInputStream stream = newStream(100);
65+
assertThrows(SocketTimeoutException.class, () -> stream.read(new byte[8], 0, 8));
66+
}
67+
68+
@Test
69+
void eofBeforeTimeoutReturnsMinusOne() throws Exception {
70+
ChannelInputStream stream = newStream(500);
71+
scheduleAfter(50, stream::eof);
72+
assertEquals(-1, stream.read(new byte[8], 0, 8));
73+
}
74+
75+
@Test
76+
void dataArrivesBeforeTimeout() throws Exception {
77+
ChannelInputStream stream = newStream(500);
78+
byte[] data = "hello".getBytes();
79+
scheduleAfter(50, () -> stream.receive(data, 0, data.length));
80+
81+
byte[] buf = new byte[data.length];
82+
int read = stream.read(buf, 0, buf.length);
83+
assertEquals(data.length, read);
84+
assertArrayEquals(data, buf);
85+
}
86+
87+
@Test
88+
void notifyErrorThrowsBeforeTimeout() throws Exception {
89+
ChannelInputStream stream = newStream(500);
90+
SSHException error = new SSHException("remote error");
91+
scheduleAfter(50, () -> stream.notifyError(error));
92+
93+
SSHException thrown = assertThrows(SSHException.class, () -> stream.read(new byte[8], 0, 8));
94+
assertSame(error, thrown);
95+
}
96+
97+
@Test
98+
void zeroTimeoutBlocksUntilEof() throws Exception {
99+
ChannelInputStream stream = newStream(0);
100+
scheduleAfter(100, stream::eof);
101+
assertEquals(-1, stream.read(new byte[8], 0, 8));
102+
}
103+
104+
private ChannelInputStream newStream(int timeoutMs) {
105+
Config config = mock(Config.class);
106+
when(config.getMaxCircularBufferSize()).thenReturn(16 * 1024 * 1024);
107+
Transport trans = mock(Transport.class);
108+
when(trans.getConfig()).thenReturn(config);
109+
return new ChannelInputStream(chan, trans, win, timeoutMs);
110+
}
111+
112+
@FunctionalInterface
113+
private interface ThrowingRunnable {
114+
void run() throws Exception;
115+
}
116+
117+
private void scheduleAfter(long delayMs, ThrowingRunnable action) {
118+
scheduler.schedule(() -> {
119+
try {
120+
action.run();
121+
} catch (Exception e) {
122+
throw new RuntimeException(e);
123+
}
124+
}, delayMs, TimeUnit.MILLISECONDS);
125+
}
126+
}

0 commit comments

Comments
 (0)