DataSource: Tighten contract to throw if opened beyond end-of-input

Includes fixes for the HTTP implementations, which previously
broke this contract specifically in the case when a server
responds to a range request with a HTTP 200 response. To fix
this case, skipping to the requested position is moved from
read() to open(). As a side effect, this nicely simplifies
CronetDataSource!

PiperOrigin-RevId: 359737301
This commit is contained in:
olly 2021-02-26 13:26:50 +00:00 committed by Oliver Woodman
parent 72aec83a9e
commit 759b0431bb
5 changed files with 134 additions and 126 deletions

View File

@ -321,7 +321,6 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
// Accessed by the calling thread only. // Accessed by the calling thread only.
private boolean opened; private boolean opened;
private long bytesToSkip;
private long bytesRemaining; private long bytesRemaining;
// Written from the calling thread only. currentUrlRequest.start() calls ensure writes are visible // Written from the calling thread only. currentUrlRequest.start() calls ensure writes are visible
@ -577,7 +576,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
byte[] responseBody; byte[] responseBody;
try { try {
responseBody = readResponseBody(); responseBody = readResponseBody();
} catch (HttpDataSourceException e) { } catch (IOException e) {
responseBody = Util.EMPTY_BYTE_ARRAY; responseBody = Util.EMPTY_BYTE_ARRAY;
} }
@ -607,7 +606,7 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
// If we requested a range starting from a non-zero position and received a 200 rather than a // If we requested a range starting from a non-zero position and received a 200 rather than a
// 206, then the server does not support partial requests. We'll need to manually skip to the // 206, then the server does not support partial requests. We'll need to manually skip to the
// requested position. // requested position.
bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; long bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0;
// Calculate the content length. // Calculate the content length.
if (!isCompressed(responseInfo)) { if (!isCompressed(responseInfo)) {
@ -627,6 +626,14 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
opened = true; opened = true;
transferStarted(dataSpec); transferStarted(dataSpec);
try {
if (!skipFully(bytesToSkip)) {
throw new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE);
}
} catch (IOException e) {
throw new OpenException(e, dataSpec, Status.READING_RESPONSE);
}
return bytesRemaining; return bytesRemaining;
} }
@ -641,25 +648,25 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
} }
ByteBuffer readBuffer = getOrCreateReadBuffer(); ByteBuffer readBuffer = getOrCreateReadBuffer();
while (!readBuffer.hasRemaining()) { if (!readBuffer.hasRemaining()) {
// Fill readBuffer with more data from Cronet. // Fill readBuffer with more data from Cronet.
operation.close(); operation.close();
readBuffer.clear(); readBuffer.clear();
readInternal(readBuffer); try {
readInternal(readBuffer);
} catch (IOException e) {
throw new HttpDataSourceException(
e, castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ);
}
if (finished) { if (finished) {
bytesRemaining = 0; bytesRemaining = 0;
return C.RESULT_END_OF_INPUT; return C.RESULT_END_OF_INPUT;
} else {
// The operation didn't time out, fail or finish, and therefore data must have been read.
readBuffer.flip();
Assertions.checkState(readBuffer.hasRemaining());
if (bytesToSkip > 0) {
int bytesSkipped = (int) Math.min(readBuffer.remaining(), bytesToSkip);
readBuffer.position(readBuffer.position() + bytesSkipped);
bytesToSkip -= bytesSkipped;
}
} }
// The operation didn't time out, fail or finish, and therefore data must have been read.
readBuffer.flip();
Assertions.checkState(readBuffer.hasRemaining());
} }
// Ensure we read up to bytesRemaining, in case this was a Range request with finite end, but // Ensure we read up to bytesRemaining, in case this was a Range request with finite end, but
@ -718,17 +725,6 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
int readLength = buffer.remaining(); int readLength = buffer.remaining();
if (readBuffer != null) { if (readBuffer != null) {
// Skip all the bytes we can from readBuffer if there are still bytes to skip.
if (bytesToSkip != 0) {
if (bytesToSkip >= readBuffer.remaining()) {
bytesToSkip -= readBuffer.remaining();
readBuffer.position(readBuffer.limit());
} else {
readBuffer.position(readBuffer.position() + (int) bytesToSkip);
bytesToSkip = 0;
}
}
// If there is existing data in the readBuffer, read as much as possible. Return if any read. // If there is existing data in the readBuffer, read as much as possible. Return if any read.
int copyBytes = copyByteBuffer(/* src= */ readBuffer, /* dst= */ buffer); int copyBytes = copyByteBuffer(/* src= */ readBuffer, /* dst= */ buffer);
if (copyBytes != 0) { if (copyBytes != 0) {
@ -740,44 +736,23 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
} }
} }
boolean readMore = true; // Fill buffer with more data from Cronet.
while (readMore) { operation.close();
// If bytesToSkip > 0, read into intermediate buffer that we can discard instead of caller's try {
// buffer. If we do not need to skip bytes, we may write to buffer directly. readInternal(buffer);
final boolean useCallerBuffer = bytesToSkip == 0; } catch (IOException e) {
throw new HttpDataSourceException(
operation.close(); e, castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ);
if (!useCallerBuffer) {
ByteBuffer readBuffer = getOrCreateReadBuffer();
readBuffer.clear();
if (bytesToSkip < READ_BUFFER_SIZE_BYTES) {
readBuffer.limit((int) bytesToSkip);
}
}
// Fill buffer with more data from Cronet.
readInternal(useCallerBuffer ? buffer : castNonNull(readBuffer));
if (finished) {
bytesRemaining = 0;
return C.RESULT_END_OF_INPUT;
} else {
// The operation didn't time out, fail or finish, and therefore data must have been read.
Assertions.checkState(
useCallerBuffer
? readLength > buffer.remaining()
: castNonNull(readBuffer).position() > 0);
// If we meant to skip bytes, subtract what was left and repeat, otherwise, continue.
if (useCallerBuffer) {
readMore = false;
} else {
bytesToSkip -= castNonNull(readBuffer).position();
}
}
} }
final int bytesRead = readLength - buffer.remaining(); if (finished) {
bytesRemaining = 0;
return C.RESULT_END_OF_INPUT;
}
// The operation didn't time out, fail or finish, and therefore data must have been read.
Assertions.checkState(readLength > buffer.remaining());
int bytesRead = readLength - buffer.remaining();
if (bytesRemaining != C.LENGTH_UNSET) { if (bytesRemaining != C.LENGTH_UNSET) {
bytesRemaining -= bytesRead; bytesRemaining -= bytesRead;
} }
@ -885,13 +860,49 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
currentConnectTimeoutMs = clock.elapsedRealtime() + connectTimeoutMs; currentConnectTimeoutMs = clock.elapsedRealtime() + connectTimeoutMs;
} }
/**
* Attempts to skip the specified number of bytes in full.
*
* @param bytesToSkip The number of bytes to skip.
* @throws InterruptedIOException If the thread is interrupted during the operation.
* @throws IOException If an error occurs reading from the source.
* @return Whether the bytes were skipped in full. If {@code false} then the data ended before the
* specified number of bytes were skipped. Always {@code true} if {@code bytesToSkip == 0}.
*/
private boolean skipFully(long bytesToSkip) throws IOException {
if (bytesToSkip == 0) {
return true;
}
ByteBuffer readBuffer = getOrCreateReadBuffer();
while (bytesToSkip > 0) {
// Fill readBuffer with more data from Cronet.
operation.close();
readBuffer.clear();
readInternal(readBuffer);
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedIOException();
}
if (finished) {
return false;
} else {
// The operation didn't time out, fail or finish, and therefore data must have been read.
readBuffer.flip();
Assertions.checkState(readBuffer.hasRemaining());
int bytesSkipped = (int) Math.min(readBuffer.remaining(), bytesToSkip);
readBuffer.position(readBuffer.position() + bytesSkipped);
bytesToSkip -= bytesSkipped;
}
}
return true;
}
/** /**
* Reads the whole response body. * Reads the whole response body.
* *
* @return The response body. * @return The response body.
* @throws HttpDataSourceException If an error occurs reading from the source. * @throws IOException If an error occurs reading from the source.
*/ */
private byte[] readResponseBody() throws HttpDataSourceException { private byte[] readResponseBody() throws IOException {
byte[] responseBody = Util.EMPTY_BYTE_ARRAY; byte[] responseBody = Util.EMPTY_BYTE_ARRAY;
ByteBuffer readBuffer = getOrCreateReadBuffer(); ByteBuffer readBuffer = getOrCreateReadBuffer();
while (!finished) { while (!finished) {
@ -914,10 +925,10 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
* the current {@code readBuffer} object so that it is not reused in the future. * the current {@code readBuffer} object so that it is not reused in the future.
* *
* @param buffer The ByteBuffer into which the read data is stored. Must be a direct ByteBuffer. * @param buffer The ByteBuffer into which the read data is stored. Must be a direct ByteBuffer.
* @throws HttpDataSourceException If an error occurs reading from the source. * @throws IOException If an error occurs reading from the source.
*/ */
@SuppressWarnings("ReferenceEquality") @SuppressWarnings("ReferenceEquality")
private void readInternal(ByteBuffer buffer) throws HttpDataSourceException { private void readInternal(ByteBuffer buffer) throws IOException {
castNonNull(currentUrlRequest).read(buffer); castNonNull(currentUrlRequest).read(buffer);
try { try {
if (!operation.block(readTimeoutMs)) { if (!operation.block(readTimeoutMs)) {
@ -930,23 +941,18 @@ public class CronetDataSource extends BaseDataSource implements HttpDataSource {
readBuffer = null; readBuffer = null;
} }
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new HttpDataSourceException( throw new InterruptedIOException();
new InterruptedIOException(),
castNonNull(currentDataSpec),
HttpDataSourceException.TYPE_READ);
} catch (SocketTimeoutException e) { } catch (SocketTimeoutException e) {
// The operation is ongoing so replace buffer to avoid it being written to by this // The operation is ongoing so replace buffer to avoid it being written to by this
// operation during a subsequent request. // operation during a subsequent request.
if (buffer == readBuffer) { if (buffer == readBuffer) {
readBuffer = null; readBuffer = null;
} }
throw new HttpDataSourceException( throw e;
e, castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ);
} }
if (exception != null) { if (exception != null) {
throw new HttpDataSourceException( throw exception;
exception, castNonNull(currentDataSpec), HttpDataSourceException.TYPE_READ);
} }
} }

View File

@ -256,6 +256,7 @@ public final class CronetDataSourceTest {
public void requestSetsRangeHeader() throws HttpDataSourceException { public void requestSetsRangeHeader() throws HttpDataSourceException {
testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000); testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000);
mockResponseStartSuccess(); mockResponseStartSuccess();
mockReadSuccess(0, 1000);
dataSourceUnderTest.open(testDataSpec); dataSourceUnderTest.open(testDataSpec);
// The header value to add is current position to current position + length - 1. // The header value to add is current position to current position + length - 1.
@ -287,8 +288,6 @@ public final class CronetDataSourceTest {
testDataSpec = testDataSpec =
new DataSpec.Builder() new DataSpec.Builder()
.setUri(TEST_URL) .setUri(TEST_URL)
.setPosition(1000)
.setLength(5000)
.setHttpRequestHeaders(dataSpecRequestProperties) .setHttpRequestHeaders(dataSpecRequestProperties)
.build(); .build();
mockResponseStartSuccess(); mockResponseStartSuccess();
@ -1198,6 +1197,7 @@ public final class CronetDataSourceTest {
dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE); dataSourceUnderTest.setRequestProperty("Content-Type", TEST_CONTENT_TYPE);
mockSingleRedirectSuccess(); mockSingleRedirectSuccess();
mockReadSuccess(0, 1000);
testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video"); testResponseHeader.put("Set-Cookie", "testcookie=testcookie; Path=/video");
@ -1368,7 +1368,7 @@ public final class CronetDataSourceTest {
@Test @Test
public void allowDirectExecutor() throws HttpDataSourceException { public void allowDirectExecutor() throws HttpDataSourceException {
testDataSpec = new DataSpec(Uri.parse(TEST_URL), 1000, 5000); testDataSpec = new DataSpec(Uri.parse(TEST_URL));
mockResponseStartSuccess(); mockResponseStartSuccess();
dataSourceUnderTest.open(testDataSpec); dataSourceUnderTest.open(testDataSpec);

View File

@ -168,8 +168,6 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
} }
} }
private static final byte[] SKIP_BUFFER = new byte[4096];
private final Call.Factory callFactory; private final Call.Factory callFactory;
private final RequestProperties requestProperties; private final RequestProperties requestProperties;
@ -183,10 +181,8 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
@Nullable private InputStream responseByteStream; @Nullable private InputStream responseByteStream;
private boolean opened; private boolean opened;
private long bytesToSkip;
private long bytesToRead;
private long bytesSkipped; private long bytesSkipped;
private long bytesToRead;
private long bytesRead; private long bytesRead;
/** @deprecated Use {@link OkHttpDataSource.Factory} instead. */ /** @deprecated Use {@link OkHttpDataSource.Factory} instead. */
@ -332,7 +328,7 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
// If we requested a range starting from a non-zero position and received a 200 rather than a // If we requested a range starting from a non-zero position and received a 200 rather than a
// 206, then the server does not support partial requests. We'll need to manually skip to the // 206, then the server does not support partial requests. We'll need to manually skip to the
// requested position. // requested position.
bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; long bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0;
// Determine the length of the data to be read, after skipping. // Determine the length of the data to be read, after skipping.
if (dataSpec.length != C.LENGTH_UNSET) { if (dataSpec.length != C.LENGTH_UNSET) {
@ -345,13 +341,21 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
opened = true; opened = true;
transferStarted(dataSpec); transferStarted(dataSpec);
try {
if (!skipFully(bytesToSkip)) {
throw new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE);
}
} catch (IOException e) {
closeConnectionQuietly();
throw new HttpDataSourceException(e, dataSpec, HttpDataSourceException.TYPE_OPEN);
}
return bytesToRead; return bytesToRead;
} }
@Override @Override
public int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException { public int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException {
try { try {
skipInternal();
return readInternal(buffer, offset, readLength); return readInternal(buffer, offset, readLength);
} catch (IOException e) { } catch (IOException e) {
throw new HttpDataSourceException( throw new HttpDataSourceException(
@ -369,8 +373,8 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
} }
/** /**
* Returns the number of bytes that have been skipped since the most recent call to * Returns the number of bytes that were skipped during the most recent call to {@link
* {@link #open(DataSpec)}. * #open(DataSpec)}.
* *
* @return The number of bytes skipped. * @return The number of bytes skipped.
*/ */
@ -454,30 +458,32 @@ public class OkHttpDataSource extends BaseDataSource implements HttpDataSource {
} }
/** /**
* Skips any bytes that need skipping. Else does nothing. * Attempts to skip the specified number of bytes in full.
* <p>
* This implementation is based roughly on {@code libcore.io.Streams.skipByReading()}.
* *
* @param bytesToSkip The number of bytes to skip.
* @throws InterruptedIOException If the thread is interrupted during the operation. * @throws InterruptedIOException If the thread is interrupted during the operation.
* @throws EOFException If the end of the input stream is reached before the bytes are skipped. * @throws IOException If an error occurs reading from the source.
* @return Whether the bytes were skipped in full. If {@code false} then the data ended before the
* specified number of bytes were skipped. Always {@code true} if {@code bytesToSkip == 0}.
*/ */
private void skipInternal() throws IOException { private boolean skipFully(long bytesToSkip) throws IOException {
if (bytesSkipped == bytesToSkip) { if (bytesToSkip == 0) {
return; return true;
} }
byte[] skipBuffer = new byte[4096];
while (bytesSkipped != bytesToSkip) { while (bytesSkipped != bytesToSkip) {
int readLength = (int) min(bytesToSkip - bytesSkipped, SKIP_BUFFER.length); int readLength = (int) min(bytesToSkip - bytesSkipped, skipBuffer.length);
int read = castNonNull(responseByteStream).read(SKIP_BUFFER, 0, readLength); int read = castNonNull(responseByteStream).read(skipBuffer, 0, readLength);
if (Thread.currentThread().isInterrupted()) { if (Thread.currentThread().isInterrupted()) {
throw new InterruptedIOException(); throw new InterruptedIOException();
} }
if (read == -1) { if (read == -1) {
throw new EOFException(); return false;
} }
bytesSkipped += read; bytesSkipped += read;
bytesTransferred(read); bytesTransferred(read);
} }
return true;
} }
/** /**

View File

@ -46,7 +46,6 @@ import java.util.Map;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream; import java.util.zip.GZIPInputStream;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
/** /**
* An {@link HttpDataSource} that uses Android's {@link HttpURLConnection}. * An {@link HttpDataSource} that uses Android's {@link HttpURLConnection}.
@ -221,14 +220,11 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
@Nullable private DataSpec dataSpec; @Nullable private DataSpec dataSpec;
@Nullable private HttpURLConnection connection; @Nullable private HttpURLConnection connection;
@Nullable private InputStream inputStream; @Nullable private InputStream inputStream;
private byte @MonotonicNonNull [] skipBuffer;
private boolean opened; private boolean opened;
private int responseCode; private int responseCode;
private long bytesToSkip;
private long bytesToRead;
private long bytesSkipped; private long bytesSkipped;
private long bytesToRead;
private long bytesRead; private long bytesRead;
/** @deprecated Use {@link DefaultHttpDataSource.Factory} instead. */ /** @deprecated Use {@link DefaultHttpDataSource.Factory} instead. */
@ -400,7 +396,7 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
// If we requested a range starting from a non-zero position and received a 200 rather than a // If we requested a range starting from a non-zero position and received a 200 rather than a
// 206, then the server does not support partial requests. We'll need to manually skip to the // 206, then the server does not support partial requests. We'll need to manually skip to the
// requested position. // requested position.
bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; long bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0;
// Determine the length of the data to be read, after skipping. // Determine the length of the data to be read, after skipping.
boolean isCompressed = isCompressed(connection); boolean isCompressed = isCompressed(connection);
@ -432,13 +428,21 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
opened = true; opened = true;
transferStarted(dataSpec); transferStarted(dataSpec);
try {
if (!skipFully(bytesToSkip)) {
throw new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE);
}
} catch (IOException e) {
closeConnectionQuietly();
throw new HttpDataSourceException(e, dataSpec, HttpDataSourceException.TYPE_OPEN);
}
return bytesToRead; return bytesToRead;
} }
@Override @Override
public int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException { public int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException {
try { try {
skipInternal();
return readInternal(buffer, offset, readLength); return readInternal(buffer, offset, readLength);
} catch (IOException e) { } catch (IOException e) {
throw new HttpDataSourceException( throw new HttpDataSourceException(
@ -480,8 +484,8 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
} }
/** /**
* Returns the number of bytes that have been skipped since the most recent call to * Returns the number of bytes that were skipped during the most recent call to {@link
* {@link #open(DataSpec)}. * #open(DataSpec)}.
* *
* @return The number of bytes skipped. * @return The number of bytes skipped.
*/ */
@ -725,22 +729,19 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
} }
/** /**
* Skips any bytes that need skipping. Else does nothing. * Attempts to skip the specified number of bytes in full.
* <p>
* This implementation is based roughly on {@code libcore.io.Streams.skipByReading()}.
* *
* @param bytesToSkip The number of bytes to skip.
* @throws InterruptedIOException If the thread is interrupted during the operation. * @throws InterruptedIOException If the thread is interrupted during the operation.
* @throws EOFException If the end of the input stream is reached before the bytes are skipped. * @throws IOException If an error occurs reading from the source.
* @return Whether the bytes were skipped in full. If {@code false} then the data ended before the
* specified number of bytes were skipped. Always {@code true} if {@code bytesToSkip == 0}.
*/ */
private void skipInternal() throws IOException { private boolean skipFully(long bytesToSkip) throws IOException {
if (bytesSkipped == bytesToSkip) { if (bytesToSkip == 0) {
return; return true;
} }
byte[] skipBuffer = new byte[4096];
if (skipBuffer == null) {
skipBuffer = new byte[4096];
}
while (bytesSkipped != bytesToSkip) { while (bytesSkipped != bytesToSkip) {
int readLength = (int) min(bytesToSkip - bytesSkipped, skipBuffer.length); int readLength = (int) min(bytesToSkip - bytesSkipped, skipBuffer.length);
int read = castNonNull(inputStream).read(skipBuffer, 0, readLength); int read = castNonNull(inputStream).read(skipBuffer, 0, readLength);
@ -748,11 +749,12 @@ public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSou
throw new InterruptedIOException(); throw new InterruptedIOException();
} }
if (read == -1) { if (read == -1) {
throw new EOFException(); return false;
} }
bytesSkipped += read; bytesSkipped += read;
bytesTransferred(read); bytesTransferred(read);
} }
return true;
} }
/** /**

View File

@ -297,14 +297,8 @@ public abstract class DataSourceContractTest {
DataSpec dataSpec = DataSpec dataSpec =
new DataSpec.Builder().setUri(resource.getUri()).setPosition(resourceLength + 1).build(); new DataSpec.Builder().setUri(resource.getUri()).setPosition(resourceLength + 1).build();
try { try {
dataSource.open(dataSpec); IOException exception = assertThrows(IOException.class, () -> dataSource.open(dataSpec));
// TODO: For any cases excluded from the requirement that a position-out-of-range exception assertThat(DataSourceException.isCausedByPositionOutOfRange(exception)).isTrue();
// is thrown, decide what the allowed behavior should be for the first read, and assert it.
} catch (IOException e) {
// TODO: Decide whether to assert that a position-out-of-range exception must or must not be
// thrown (with exclusions if necessary), rather than just asserting it must be a
// position-out-of-range exception *if* one is thrown at all.
assertThat(DataSourceException.isCausedByPositionOutOfRange(e)).isTrue();
} finally { } finally {
dataSource.close(); dataSource.close();
} }