Skip to content

Commit cb9acdf

Browse files
Fix ReadAheadRemoteFileInputStream not reading the whole file if a buffer is too big
If an instance of ReadAheadRemoteFileInputStream before this change is wrapped into a BufferedInputStream with a big buffer, the SSH client requests big packets from the server. It turned out that if the server had sent a response smaller than requested, the client wouldn't have adjusted to decreased window size, and would have read the file incorrectly. This change detects cases when the server is not able to fulfil client's requests. Since this change, the client adjusts the maximum request length, sends new read-ahead requests, and starts to ignore all read-ahead requests sent earlier. Just specifying some allegedly small constant buffer size wouldn't have helped in all possible cases. There is no way to explicitly get the maximum request length inside a client. All that limits differ from server to server. For instance, OpenSSH defines SFTP_MAX_MSG_LENGTH as 256 * 1024. Apache SSHD defines MAX_READDATA_PACKET_LENGTH as 63 * 1024, and it allows to redefine that size. Interestingly, a similar issue hierynomus#183 was fixed many years ago, but the bug was actually in the code introduced for that fix.
1 parent 50efeb6 commit cb9acdf

File tree

2 files changed

+112
-46
lines changed

2 files changed

+112
-46
lines changed

src/main/java/net/schmizz/sshj/sftp/RemoteFile.java

+58-43
Original file line numberDiff line numberDiff line change
@@ -220,16 +220,42 @@ public int read(byte[] into, int off, int len) throws IOException {
220220

221221
public class ReadAheadRemoteFileInputStream
222222
extends InputStream {
223+
private class UnconfirmedRead {
224+
private final long offset;
225+
private final Promise<Response, SFTPException> promise;
226+
private final int length;
227+
228+
private UnconfirmedRead(long offset, int length, Promise<Response, SFTPException> promise) {
229+
this.offset = offset;
230+
this.length = length;
231+
this.promise = promise;
232+
}
233+
234+
UnconfirmedRead(long offset, int length) throws IOException {
235+
this(offset, length, RemoteFile.this.asyncRead(offset, length));
236+
}
237+
238+
public long getOffset() {
239+
return offset;
240+
}
241+
242+
public Promise<Response, SFTPException> getPromise() {
243+
return promise;
244+
}
245+
246+
public int getLength() {
247+
return length;
248+
}
249+
}
223250

224251
private final byte[] b = new byte[1];
225252

226253
private final int maxUnconfirmedReads;
227254
private final long readAheadLimit;
228-
private final Queue<Promise<Response, SFTPException>> unconfirmedReads = new LinkedList<Promise<Response, SFTPException>>();
229-
private final Queue<Long> unconfirmedReadOffsets = new LinkedList<Long>();
255+
private final Queue<UnconfirmedRead> unconfirmedReads = new LinkedList<>();
230256

231-
private long requestOffset;
232-
private long responseOffset;
257+
private long currentOffset;
258+
private int maxReadLength = Integer.MAX_VALUE;
233259
private boolean eof;
234260

235261
public ReadAheadRemoteFileInputStream(int maxUnconfirmedReads) {
@@ -247,28 +273,42 @@ public ReadAheadRemoteFileInputStream(int maxUnconfirmedReads, long fileOffset,
247273
assert 0 <= fileOffset;
248274

249275
this.maxUnconfirmedReads = maxUnconfirmedReads;
250-
this.requestOffset = this.responseOffset = fileOffset;
276+
this.currentOffset = fileOffset;
251277
this.readAheadLimit = readAheadLimit > 0 ? fileOffset + readAheadLimit : Long.MAX_VALUE;
252278
}
253279

254280
private ByteArrayInputStream pending = new ByteArrayInputStream(new byte[0]);
255281

256282
private boolean retrieveUnconfirmedRead(boolean blocking) throws IOException {
257-
if (unconfirmedReads.size() <= 0) {
283+
final UnconfirmedRead unconfirmedRead = unconfirmedReads.peek();
284+
if (unconfirmedRead == null || !blocking && !unconfirmedRead.getPromise().isDelivered()) {
258285
return false;
259286
}
287+
unconfirmedReads.remove(unconfirmedRead);
260288

261-
if (!blocking && !unconfirmedReads.peek().isDelivered()) {
262-
return false;
263-
}
264-
265-
unconfirmedReadOffsets.remove();
266-
final Response res = unconfirmedReads.remove().retrieve(requester.getTimeoutMs(), TimeUnit.MILLISECONDS);
289+
final Response res = unconfirmedRead.promise.retrieve(requester.getTimeoutMs(), TimeUnit.MILLISECONDS);
267290
switch (res.getType()) {
268291
case DATA:
269292
int recvLen = res.readUInt32AsInt();
270-
responseOffset += recvLen;
271-
pending = new ByteArrayInputStream(res.array(), res.rpos(), recvLen);
293+
if (unconfirmedRead.offset == currentOffset) {
294+
currentOffset += recvLen;
295+
pending = new ByteArrayInputStream(res.array(), res.rpos(), recvLen);
296+
297+
if (recvLen < unconfirmedRead.length) {
298+
// The server returned a packet smaller than the client had requested.
299+
// It can be caused by at least one of the following:
300+
// * The file has been read fully. Then, few futile read requests can be sent during
301+
// the next read(), but the file will be downloaded correctly anyway.
302+
// * The server shapes the request length. Then, the read window will be adjusted,
303+
// and all further read-ahead requests won't be shaped.
304+
// * The file on the server is not a regular file, it is something like fifo.
305+
// Then, the window will shrink, and the client will start reading the file slower than it
306+
// hypothetically can. It must be a rare case, and it is not worth implementing a sort of
307+
// congestion control algorithm here.
308+
maxReadLength = recvLen;
309+
unconfirmedReads.clear();
310+
}
311+
}
272312
break;
273313

274314
case STATUS:
@@ -296,49 +336,24 @@ public int read(byte[] into, int off, int len) throws IOException {
296336
// we also need to go here for len <= 0, because pending may be at
297337
// EOF in which case it would return -1 instead of 0
298338

339+
long requestOffset = currentOffset;
299340
while (unconfirmedReads.size() <= maxUnconfirmedReads) {
300341
// Send read requests as long as there is no EOF and we have not reached the maximum parallelism
301-
int reqLen = Math.max(1024, len); // don't be shy!
342+
int reqLen = Math.min(Math.max(1024, len), maxReadLength);
302343
if (readAheadLimit > requestOffset) {
303344
long remaining = readAheadLimit - requestOffset;
304345
if (reqLen > remaining) {
305346
reqLen = (int) remaining;
306347
}
307348
}
308-
unconfirmedReads.add(RemoteFile.this.asyncRead(requestOffset, reqLen));
309-
unconfirmedReadOffsets.add(requestOffset);
349+
unconfirmedReads.add(new UnconfirmedRead(requestOffset, reqLen));
310350
requestOffset += reqLen;
311351
if (requestOffset >= readAheadLimit) {
312352
break;
313353
}
314354
}
315355

316-
long nextOffset = unconfirmedReadOffsets.peek();
317-
if (responseOffset != nextOffset) {
318-
319-
// the server could not give us all the data we needed, so
320-
// we try to fill the gap synchronously
321-
322-
assert responseOffset < nextOffset;
323-
assert 0 < (nextOffset - responseOffset);
324-
assert (nextOffset - responseOffset) <= Integer.MAX_VALUE;
325-
326-
byte[] buf = new byte[(int) (nextOffset - responseOffset)];
327-
int recvLen = RemoteFile.this.read(responseOffset, buf, 0, buf.length);
328-
329-
if (recvLen < 0) {
330-
eof = true;
331-
return -1;
332-
}
333-
334-
if (0 == recvLen) {
335-
// avoid infinite loops
336-
throw new SFTPException("Unexpected response size (0), bailing out");
337-
}
338-
339-
responseOffset += recvLen;
340-
pending = new ByteArrayInputStream(buf, 0, recvLen);
341-
} else if (!retrieveUnconfirmedRead(true /*blocking*/)) {
356+
if (!retrieveUnconfirmedRead(true /*blocking*/)) {
342357

343358
// this may happen if we change prefetch strategy
344359
// currently, we should never get here...

src/test/java/com/hierynomus/sshj/sftp/RemoteFileTest.java

+54-3
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,24 @@
1717

1818
import com.hierynomus.sshj.test.SshFixture;
1919
import net.schmizz.sshj.SSHClient;
20+
import net.schmizz.sshj.common.ByteArrayUtils;
2021
import net.schmizz.sshj.sftp.OpenMode;
2122
import net.schmizz.sshj.sftp.RemoteFile;
2223
import net.schmizz.sshj.sftp.SFTPEngine;
2324
import net.schmizz.sshj.sftp.SFTPException;
25+
import org.apache.sshd.common.util.io.IoUtils;
2426
import org.junit.Rule;
2527
import org.junit.Test;
2628
import org.junit.rules.TemporaryFolder;
2729

28-
import java.io.File;
29-
import java.io.IOException;
30-
import java.io.InputStream;
30+
import java.io.*;
31+
import java.security.SecureRandom;
3132
import java.util.EnumSet;
3233
import java.util.Random;
3334

3435
import static org.hamcrest.CoreMatchers.equalTo;
3536
import static org.hamcrest.MatcherAssert.assertThat;
37+
import static org.junit.Assert.assertEquals;
3638
import static org.junit.Assert.fail;
3739

3840
public class RemoteFileTest {
@@ -174,4 +176,53 @@ public void limitedReadAheadInputStream() throws IOException {
174176

175177
assertThat("The written and received data should match", data, equalTo(test2));
176178
}
179+
180+
@Test
181+
public void shouldReadCorrectlyWhenWrappedInBufferedStream_FullSizeBuffer() throws IOException {
182+
doTestShouldReadCorrectlyWhenWrappedInBufferedStream(1024 * 1024, 1024 * 1024);
183+
}
184+
185+
@Test
186+
public void shouldReadCorrectlyWhenWrappedInBufferedStream_HalfSizeBuffer() throws IOException {
187+
doTestShouldReadCorrectlyWhenWrappedInBufferedStream(1024 * 1024, 512 * 1024);
188+
}
189+
190+
@Test
191+
public void shouldReadCorrectlyWhenWrappedInBufferedStream_QuarterSizeBuffer() throws IOException {
192+
doTestShouldReadCorrectlyWhenWrappedInBufferedStream(1024 * 1024, 256 * 1024);
193+
}
194+
195+
@Test
196+
public void shouldReadCorrectlyWhenWrappedInBufferedStream_SmallSizeBuffer() throws IOException {
197+
doTestShouldReadCorrectlyWhenWrappedInBufferedStream(1024 * 1024, 1024);
198+
}
199+
200+
private void doTestShouldReadCorrectlyWhenWrappedInBufferedStream(int fileSize, int bufferSize) throws IOException {
201+
SSHClient ssh = fixture.setupConnectedDefaultClient();
202+
ssh.authPassword("test", "test");
203+
SFTPEngine sftp = new SFTPEngine(ssh).init();
204+
205+
final byte[] expected = new byte[fileSize];
206+
new SecureRandom(new byte[] { 31 }).nextBytes(expected);
207+
208+
File file = temp.newFile("shouldReadCorrectlyWhenWrappedInBufferedStream.bin");
209+
try (OutputStream fStream = new FileOutputStream(file)) {
210+
IoUtils.copy(new ByteArrayInputStream(expected), fStream);
211+
}
212+
213+
RemoteFile rf = sftp.open(file.getPath());
214+
final byte[] actual;
215+
try (InputStream inputStream = new BufferedInputStream(
216+
rf.new ReadAheadRemoteFileInputStream(10),
217+
bufferSize)
218+
) {
219+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
220+
IoUtils.copy(inputStream, baos, expected.length);
221+
actual = baos.toByteArray();
222+
}
223+
224+
assertEquals("The file should be fully read", expected.length, actual.length);
225+
assertThat("The file should be read correctly",
226+
ByteArrayUtils.equals(expected, 0, actual, 0, expected.length));
227+
}
177228
}

0 commit comments

Comments
 (0)