diff --git a/vertx-core/src/main/java/io/vertx/core/http/HttpServerResponse.java b/vertx-core/src/main/java/io/vertx/core/http/HttpServerResponse.java index 027bf9df78b..2c8e77ef7c5 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/HttpServerResponse.java +++ b/vertx-core/src/main/java/io/vertx/core/http/HttpServerResponse.java @@ -357,6 +357,18 @@ default Future sendFile(String filename) { return sendFile(filename, 0); } + /** + * Same as {@link #sendFile(String, long, long, SendFileOptions)} with offset {@code 0} and length + * {@code Long.MAX_VALUE} which means until the end of the file. + * + * @param filename path to the file to serve + * @param options send-file options + * @return a future completed with the body result + */ + default Future sendFile(String filename, SendFileOptions options) { + return sendFile(filename, 0, options); + } + /** * Same as {@link #sendFile(String, long, long)} using length @code{Long.MAX_VALUE} which means until the end of the * file. @@ -373,6 +385,19 @@ default Future sendFile(String filename, long offset) { return sendFile(filename, offset, Long.MAX_VALUE); } + /** + * Same as {@link #sendFile(String, long, long, SendFileOptions)} using length {@code Long.MAX_VALUE} + * which means until the end of the file. + * + * @param filename path to the file to serve + * @param offset offset to start serving from + * @param options send-file options + * @return a future completed with the body result + */ + default Future sendFile(String filename, long offset, SendFileOptions options) { + return sendFile(filename, offset, Long.MAX_VALUE, options); + } + /** * Ask the OS to stream a file as specified by {@code filename} directly * from disk to the outgoing connection, bypassing userspace altogether @@ -391,6 +416,22 @@ default Future sendFile(String filename, long offset) { */ Future sendFile(String filename, long offset, long length); + /** + * Same as {@link #sendFile(String, long, long)} with send-file options. + *

+ * {@link SendFileOptions#getChunkSize()} configures the chunk size used by the fallback streaming path. + * It is ignored when the file-region/sendfile path is used. + * + * @param filename path to the file to serve + * @param offset offset to start serving from + * @param length the number of bytes to send + * @param options send-file options + * @return a future completed with the body result + */ + default Future sendFile(String filename, long offset, long length, SendFileOptions options) { + return sendFile(filename, offset, length); + } + /** * Same as {@link #sendFile(FileChannel, long)} using length @code{Long.MAX_VALUE} which means until the end of the * file. @@ -408,6 +449,20 @@ default Future sendFile(FileChannel channel) { return sendFile(channel, 0); } + /** + * Same as {@link #sendFile(FileChannel, long, long, SendFileOptions)} with offset {@code 0} and length + * {@code Long.MAX_VALUE} which means until the end of the file. + * + * @param channel the file channel to the file to serve + * @param options send-file options + * @return a future completed with the body result + */ + @GenIgnore(GenIgnore.PERMITTED_TYPE) + @Unstable + default Future sendFile(FileChannel channel, SendFileOptions options) { + return sendFile(channel, 0, options); + } + /** * Same as {@link #sendFile(FileChannel, long, long)} using length @code{Long.MAX_VALUE} which means until the end of the * file. @@ -426,6 +481,21 @@ default Future sendFile(FileChannel channel, long offset) { return sendFile(channel, offset, Long.MAX_VALUE); } + /** + * Same as {@link #sendFile(FileChannel, long, long, SendFileOptions)} using length {@code Long.MAX_VALUE} + * which means until the end of the file. + * + * @param channel the file channel to the file to serve + * @param offset offset to start serving from + * @param options send-file options + * @return a future completed with the body result + */ + @GenIgnore(GenIgnore.PERMITTED_TYPE) + @Unstable + default Future sendFile(FileChannel channel, long offset, SendFileOptions options) { + return sendFile(channel, offset, Long.MAX_VALUE, options); + } + /** * Ask the OS to stream a file as specified by {@code channel} directly * from disk to the outgoing connection, bypassing userspace altogether @@ -449,6 +519,24 @@ default Future sendFile(FileChannel channel, long offset) { @Unstable Future sendFile(FileChannel channel, long offset, long length); + /** + * Same as {@link #sendFile(FileChannel, long, long)} with send-file options. + *

+ * {@link SendFileOptions#getChunkSize()} configures the chunk size used by the fallback streaming path. + * It is ignored when the file-region/sendfile path is used. + * + * @param channel the file channel to the file to serve + * @param offset offset to start serving from + * @param length the number of bytes to send + * @param options send-file options + * @return a future completed with the body result + */ + @GenIgnore(GenIgnore.PERMITTED_TYPE) + @Unstable + default Future sendFile(FileChannel channel, long offset, long length, SendFileOptions options) { + return sendFile(channel, offset, length); + } + /** * Same as {@link #sendFile(FileChannel)} with {@link RandomAccessFile} *

@@ -465,6 +553,20 @@ default Future sendFile(RandomAccessFile file) { return sendFile(file, 0); } + /** + * Same as {@link #sendFile(RandomAccessFile, long, long, SendFileOptions)} with offset {@code 0} and length + * {@code Long.MAX_VALUE} which means until the end of the file. + * + * @param file the file to serve + * @param options send-file options + * @return a future completed with the body result + */ + @GenIgnore(GenIgnore.PERMITTED_TYPE) + @Unstable + default Future sendFile(RandomAccessFile file, SendFileOptions options) { + return sendFile(file, 0, options); + } + /** * * Same as {@link #sendFile(FileChannel, long)} with {@link RandomAccessFile} @@ -483,6 +585,21 @@ default Future sendFile(RandomAccessFile file, long offset) { return sendFile(file, offset, Long.MAX_VALUE); } + /** + * Same as {@link #sendFile(RandomAccessFile, long, long, SendFileOptions)} using length {@code Long.MAX_VALUE} + * which means until the end of the file. + * + * @param file the file to serve + * @param offset offset to start serving from + * @param options send-file options + * @return a future completed with the body result + */ + @GenIgnore(GenIgnore.PERMITTED_TYPE) + @Unstable + default Future sendFile(RandomAccessFile file, long offset, SendFileOptions options) { + return sendFile(file, offset, Long.MAX_VALUE, options); + } + /** * Same as {@link #sendFile(FileChannel, long, long)} with {@link RandomAccessFile} *

@@ -499,6 +616,24 @@ default Future sendFile(RandomAccessFile file, long offset) { @Unstable Future sendFile(RandomAccessFile file, long offset, long length); + /** + * Same as {@link #sendFile(RandomAccessFile, long, long)} with send-file options. + *

+ * {@link SendFileOptions#getChunkSize()} configures the chunk size used by the fallback streaming path. + * It is ignored when the file-region/sendfile path is used. + * + * @param file the file to serve + * @param offset offset to start serving from + * @param length the number of bytes to send + * @param options send-file options + * @return a future completed with the body result + */ + @GenIgnore(GenIgnore.PERMITTED_TYPE) + @Unstable + default Future sendFile(RandomAccessFile file, long offset, long length, SendFileOptions options) { + return sendFile(file, offset, length); + } + /** * @return has the response already ended? */ diff --git a/vertx-core/src/main/java/io/vertx/core/http/SendFileOptions.java b/vertx-core/src/main/java/io/vertx/core/http/SendFileOptions.java new file mode 100644 index 00000000000..ed4659b109d --- /dev/null +++ b/vertx-core/src/main/java/io/vertx/core/http/SendFileOptions.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.core.http; + +import io.vertx.codegen.annotations.DataObject; +import io.vertx.codegen.annotations.Unstable; +import io.vertx.codegen.json.annotations.JsonGen; +import io.vertx.core.impl.Arguments; +import io.vertx.core.json.JsonObject; + +/** + * Options controlling how {@link HttpServerResponse#sendFile(String)} streams a file when + * the file-region/sendfile path cannot be used. + */ +@DataObject +@JsonGen(publicConverter = false) +@Unstable +public class SendFileOptions { + + /** + * The default chunk size used by the send-file fallback path. + */ + public static final int DEFAULT_CHUNK_SIZE = 8192; + + private int chunkSize; + + /** + * Create default send-file options. + */ + public SendFileOptions() { + chunkSize = DEFAULT_CHUNK_SIZE; + } + + /** + * Create send-file options from JSON. + * + * @param json the JSON object + */ + public SendFileOptions(JsonObject json) { + this(); + SendFileOptionsConverter.fromJson(json, this); + } + + /** + * Copy constructor. + * + * @param other the options to copy + */ + public SendFileOptions(SendFileOptions other) { + chunkSize = other.chunkSize; + } + + /** + * @return the chunk size, in bytes, used when the file is streamed through the fallback path + */ + public int getChunkSize() { + return chunkSize; + } + + /** + * Set the chunk size, in bytes, used when the file is streamed through the fallback path. + *

+ * This option is ignored when the file can be transferred using the file-region/sendfile path. + * + * @param chunkSize the chunk size in bytes + * @return a reference to this, so the API can be used fluently + */ + public SendFileOptions setChunkSize(int chunkSize) { + Arguments.require(chunkSize > 0, "chunkSize must be > 0"); + this.chunkSize = chunkSize; + return this; + } + + /** + * @return a JSON representation of these options + */ + public JsonObject toJson() { + JsonObject json = new JsonObject(); + SendFileOptionsConverter.toJson(this, json); + return json; + } +} diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/HttpServerResponseImpl.java b/vertx-core/src/main/java/io/vertx/core/http/impl/HttpServerResponseImpl.java index 9db63774d40..ecce920f0f0 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/HttpServerResponseImpl.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/HttpServerResponseImpl.java @@ -511,6 +511,11 @@ public HttpServerResponse drainHandler(Handler handler) { @Override public Future sendFile(String filename, long offset, long length) { + return sendFile(filename, offset, length, new SendFileOptions()); + } + + @Override + public Future sendFile(String filename, long offset, long length, SendFileOptions options) { if (offset < 0) { return context.failedFuture("offset : " + offset + " (expected: >= 0)"); } @@ -521,14 +526,19 @@ public Future sendFile(String filename, long offset, long length) { checkValid(); } if (conn.supportsSendFile()) { - return sendFileInternal(filename, offset, length); + return sendFileInternal(filename, offset, length, options); } else { - return sendAsyncFile(filename, offset, length); + return sendAsyncFile(filename, offset, length, options); } } @Override public Future sendFile(RandomAccessFile file, long offset, long length) { + return sendFile(file, offset, length, new SendFileOptions()); + } + + @Override + public Future sendFile(RandomAccessFile file, long offset, long length, SendFileOptions options) { if (!headersMap.contains(io.vertx.core.http.HttpHeaders.CONTENT_TYPE)) { headersMap.set(CONTENT_TYPE, APPLICATION_OCTET_STREAM); } @@ -538,11 +548,16 @@ public Future sendFile(RandomAccessFile file, long offset, long length) { } catch (IOException e) { return context.failedFuture(e); } - return sendFileInternal(offset, length, size, file, null, false); + return sendFileInternal(offset, length, size, file, null, false, options); } @Override public Future sendFile(FileChannel channel, long offset, long length) { + return sendFile(channel, offset, length, new SendFileOptions()); + } + + @Override + public Future sendFile(FileChannel channel, long offset, long length, SendFileOptions options) { if (!headersMap.contains(io.vertx.core.http.HttpHeaders.CONTENT_TYPE)) { headersMap.set(CONTENT_TYPE, APPLICATION_OCTET_STREAM); } @@ -552,13 +567,14 @@ public Future sendFile(FileChannel channel, long offset, long length) { } catch (IOException e) { return context.failedFuture(e); } - return sendFileInternal(offset, length, size, null, channel, false); + return sendFileInternal(offset, length, size, null, channel, false, options); } - private Future sendAsyncFile(String filename, long offset, long length) { + private Future sendAsyncFile(String filename, long offset, long length, SendFileOptions options) { return HttpUtils .resolveFile(context, filename, offset, length) .compose(file -> { + file.setReadBufferSize(options.getChunkSize()); long fileLength = file.getReadLength(); long contentLength = Math.min(length, fileLength); // fail early before status code/headers are written to the response @@ -578,7 +594,7 @@ private Future sendAsyncFile(String filename, long offset, long length) { }); } - private Future sendFileInternal(String filename, long offset, long length) { + private Future sendFileInternal(String filename, long offset, long length, SendFileOptions options) { File file = context.owner().fileResolver().resolve(filename); long size; RandomAccessFile raf; @@ -595,10 +611,10 @@ private Future sendFileInternal(String filename, long offset, long length) } headersMap.set(CONTENT_TYPE, mimeType); } - return sendFileInternal(offset, length, size, raf, null, true); + return sendFileInternal(offset, length, size, raf, null, true, options); } - private Future sendFileInternal(long offset, long length, long size, RandomAccessFile file, FileChannel channel, boolean close) { + private Future sendFileInternal(long offset, long length, long size, RandomAccessFile file, FileChannel channel, boolean close, SendFileOptions options) { Future fut = null; try { long actualLength = Math.min(length, size - offset); @@ -612,9 +628,9 @@ private Future sendFileInternal(long offset, long length, long size, Rando channel = file.getChannel(); } if (close) { - chunkedFile = new ChunkedNioFile(channel, actualOffset, actualLength, 8192); + chunkedFile = new ChunkedNioFile(channel, actualOffset, actualLength, options.getChunkSize()); } else { - chunkedFile = new UncloseableChunkedNioFile(channel, actualOffset, actualLength); + chunkedFile = new UncloseableChunkedNioFile(channel, actualOffset, actualLength, options.getChunkSize()); } } catch (IOException e) { return context.failedFuture(e); diff --git a/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ServerResponse.java b/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ServerResponse.java index 9cc8b7e6103..1442abf58af 100644 --- a/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ServerResponse.java +++ b/vertx-core/src/main/java/io/vertx/core/http/impl/http1/Http1ServerResponse.java @@ -442,6 +442,11 @@ public Future end() { @Override public Future sendFile(String filename, long offset, long length) { + return sendFile(filename, offset, length, new SendFileOptions()); + } + + @Override + public Future sendFile(String filename, long offset, long length, SendFileOptions options) { File file = vertx.fileResolver().resolve(filename); RandomAccessFile raf; long size; @@ -458,11 +463,16 @@ public Future sendFile(String filename, long offset, long length) { } headers.set(CONTENT_TYPE, mimeType); } - return sendFileInternal(offset, length, size, raf, null, true); + return sendFileInternal(offset, length, size, raf, null, true, options); } @Override public Future sendFile(RandomAccessFile file, long offset, long length) { + return sendFile(file, offset, length, new SendFileOptions()); + } + + @Override + public Future sendFile(RandomAccessFile file, long offset, long length, SendFileOptions options) { if (!headers.contains(HttpHeaders.CONTENT_TYPE)) { headers.set(CONTENT_TYPE, APPLICATION_OCTET_STREAM); } @@ -472,11 +482,16 @@ public Future sendFile(RandomAccessFile file, long offset, long length) { } catch (IOException e) { return context.failedFuture(e); } - return sendFileInternal(offset, length, size, file, null, false); + return sendFileInternal(offset, length, size, file, null, false, options); } @Override public Future sendFile(FileChannel channel, long offset, long length) { + return sendFile(channel, offset, length, new SendFileOptions()); + } + + @Override + public Future sendFile(FileChannel channel, long offset, long length, SendFileOptions options) { if (!headers.contains(HttpHeaders.CONTENT_TYPE)) { headers.set(CONTENT_TYPE, APPLICATION_OCTET_STREAM); } @@ -486,10 +501,10 @@ public Future sendFile(FileChannel channel, long offset, long length) { } catch (IOException e) { return context.failedFuture(e); } - return sendFileInternal(offset, length, size, null, channel, false); + return sendFileInternal(offset, length, size, null, channel, false, options); } - private Future sendFileInternal(long offset, long length, long size, RandomAccessFile file, FileChannel fileChannel, boolean close) { + private Future sendFileInternal(long offset, long length, long size, RandomAccessFile file, FileChannel fileChannel, boolean close, SendFileOptions options) { Future ret = null; try { ContextInternal ctx = vertx.getOrCreateContext(); @@ -516,7 +531,7 @@ private Future sendFileInternal(long offset, long length, long size, Rando written = true; conn.write(new VertxAssembledHttpResponse(head, version, status, headers), null); FileChannel toSend = fileChannel == null ? file.getChannel() : fileChannel; - ChannelFuture channelFuture = conn.sendFile(toSend, actualOffset, actualLength); + ChannelFuture channelFuture = conn.sendFile(toSend, actualOffset, actualLength, options.getChunkSize()); PromiseInternal promise = context.promise(); ret = promise.future(); channelFuture.addListener(future -> { diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/UncloseableChunkedNioFile.java b/vertx-core/src/main/java/io/vertx/core/net/impl/UncloseableChunkedNioFile.java index c8792e23837..8ff1e56b5e7 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/UncloseableChunkedNioFile.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/UncloseableChunkedNioFile.java @@ -11,6 +11,7 @@ package io.vertx.core.net.impl; import io.netty.handler.stream.ChunkedNioFile; +import io.vertx.core.http.SendFileOptions; import java.io.IOException; import java.nio.channels.FileChannel; @@ -23,8 +24,8 @@ */ public class UncloseableChunkedNioFile extends ChunkedNioFile { - public UncloseableChunkedNioFile(FileChannel in, long offset, long length) throws IOException { - super(in, offset, length, 8192); + public UncloseableChunkedNioFile(FileChannel in, long offset, long length, int chunkSize) throws IOException { + super(in, offset, length, chunkSize); } @Override diff --git a/vertx-core/src/main/java/io/vertx/core/net/impl/VertxConnection.java b/vertx-core/src/main/java/io/vertx/core/net/impl/VertxConnection.java index 6e8373ed528..090c20a5621 100644 --- a/vertx-core/src/main/java/io/vertx/core/net/impl/VertxConnection.java +++ b/vertx-core/src/main/java/io/vertx/core/net/impl/VertxConnection.java @@ -19,6 +19,7 @@ import io.netty.util.concurrent.FutureListener; import io.netty.util.concurrent.ScheduledFuture; import io.vertx.core.Future; +import io.vertx.core.http.SendFileOptions; import io.vertx.core.Promise; import io.vertx.core.ThreadingModel; import io.vertx.core.impl.EventLoopExecutor; @@ -530,12 +531,16 @@ private void sendFileRegion(FileChannel fc, long offset, long length, ChannelPro } public ChannelFuture sendFile(FileChannel fc, long offset, long length) { + return sendFile(fc, offset, length, SendFileOptions.DEFAULT_CHUNK_SIZE); + } + + public ChannelFuture sendFile(FileChannel fc, long offset, long length, int chunkSize) { // Write the content. ChannelPromise writeFuture = chctx.newPromise(); if (!supportsFileRegion()) { // Cannot use zero-copy try { - writeToChannel(new UncloseableChunkedNioFile(fc, offset, length), writeFuture); + writeToChannel(new UncloseableChunkedNioFile(fc, offset, length, chunkSize), writeFuture); } catch (IOException e) { return chctx.newFailedFuture(e); } diff --git a/vertx-core/src/test/java/io/vertx/tests/http/SendFileOptionsTest.java b/vertx-core/src/test/java/io/vertx/tests/http/SendFileOptionsTest.java new file mode 100644 index 00000000000..d94d8dd08b7 --- /dev/null +++ b/vertx-core/src/test/java/io/vertx/tests/http/SendFileOptionsTest.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2011-2026 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + */ +package io.vertx.tests.http; + +import io.vertx.core.http.SendFileOptions; +import io.vertx.core.json.JsonObject; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class SendFileOptionsTest { + + @Test + public void testDefaults() { + SendFileOptions options = new SendFileOptions(); + assertEquals(SendFileOptions.DEFAULT_CHUNK_SIZE, options.getChunkSize()); + } + + @Test + public void testCopy() { + SendFileOptions options = new SendFileOptions().setChunkSize(64 * 1024); + SendFileOptions copy = new SendFileOptions(options); + assertEquals(options.getChunkSize(), copy.getChunkSize()); + } + + @Test + public void testJson() { + JsonObject json = new JsonObject().put("chunkSize", 64 * 1024); + SendFileOptions options = new SendFileOptions(json); + assertEquals(64 * 1024, options.getChunkSize()); + assertEquals(json, options.toJson()); + } + + @Test + public void testChunkSizeValidation() { + assertThrows(IllegalArgumentException.class, () -> new SendFileOptions().setChunkSize(0)); + assertThrows(IllegalArgumentException.class, () -> new SendFileOptions().setChunkSize(-1)); + } +} diff --git a/vertx-core/src/test/java/io/vertx/tests/http/sendfile/HttpSendFileTest.java b/vertx-core/src/test/java/io/vertx/tests/http/sendfile/HttpSendFileTest.java index b7c12ec4ad8..be920abf1c6 100644 --- a/vertx-core/src/test/java/io/vertx/tests/http/sendfile/HttpSendFileTest.java +++ b/vertx-core/src/test/java/io/vertx/tests/http/sendfile/HttpSendFileTest.java @@ -19,6 +19,7 @@ import io.vertx.core.http.HttpResponseExpectation; import io.vertx.core.http.HttpServerResponse; import io.vertx.core.http.HttpVersion; +import io.vertx.core.http.SendFileOptions; import io.vertx.test.core.Checkpoint; import io.vertx.test.core.DetectFileDescriptorLeaks; import io.vertx.test.core.FileDescriptorLeakDetectorRule; @@ -90,6 +91,50 @@ protected void sendFile(Checkpoint checkpoint, String fileName, String contentEx .await(); } + @Test + public void testSendFileWithOptions() throws Exception { + String content = TestUtils.randomUnicodeString(10000); + File file = setupFile("test-send-file-options.html", content); + SendFileOptions options = new SendFileOptions().setChunkSize(32 * 1024); + + server.requestHandler(req -> req.response().sendFile(file.getAbsolutePath(), options)); + startServer(testAddress); + + Buffer body = client.request(requestOptions) + .compose(req -> req + .send() + .expecting(that(resp -> { + assertEquals(200, resp.statusCode()); + assertEquals("text/html", resp.headers().get("Content-Type")); + assertEquals(file.length(), Long.parseLong(resp.headers().get("content-length"))); + })) + .compose(HttpClientResponse::body)) + .await(); + assertEquals(content, body.toString()); + } + + @Test + public void testSendFileRangeWithOptions() throws Exception { + String content = "0123456789abcdefghijklmnopqrstuvwxyz"; + File file = setupFile("test-send-file-options-range.html", content); + SendFileOptions options = new SendFileOptions().setChunkSize(4); + + server.requestHandler(req -> req.response().sendFile(file.getAbsolutePath(), 10, 12, options)); + startServer(testAddress); + + Buffer body = client.request(requestOptions) + .compose(req -> req + .send() + .expecting(that(resp -> { + assertEquals(200, resp.statusCode()); + assertEquals("text/html", resp.headers().get("Content-Type")); + assertEquals("12", resp.headers().get("content-length")); + })) + .compose(HttpClientResponse::body)) + .await(); + assertEquals("abcdefghijkl", body.toString()); + } + @Test public void testSendNonExistingFile() throws Exception { server.requestHandler(req -> { @@ -280,6 +325,17 @@ public void testSendFileWithFileChannel() throws Exception { } } + @Test + public void testSendFileWithFileChannelAndOptions() throws Exception { + int fileLength = 64 * 1024; + SendFileOptions options = new SendFileOptions().setChunkSize(1024); + BiFunction> sender = (file, response) -> + response.sendFile(file.getChannel(), options); + try (RandomAccessFile raf = testSendFileWithFileChannel(fileLength, sender, "application/octet-stream", fileLength)) { + assertTrue(raf.getChannel().isOpen()); + } + } + @Test public void testSendFileWithFileChannelAndExtension() throws Exception { int fileLength = 16 * 1024 * 1024;