From cca33093e8fbba8999cc2f5ff5edbfc49e72cedf Mon Sep 17 00:00:00 2001 From: Ludovic Orban Date: Wed, 29 Oct 2025 17:43:44 +0100 Subject: [PATCH] #13922 disable async context timeout when serving files asynchronously Signed-off-by: Ludovic Orban --- .../jetty/ee10/servlet/ResourceServlet.java | 11 +- .../ee10/servlet/ResourceServletTest.java | 170 ++++++++++++++++++ .../jetty/ee11/servlet/ResourceServlet.java | 11 +- .../ee11/servlet/ResourceServletTest.java | 170 ++++++++++++++++++ 4 files changed, 360 insertions(+), 2 deletions(-) diff --git a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ResourceServlet.java b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ResourceServlet.java index b2e5fe944896..437e6a0e1689 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ResourceServlet.java +++ b/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/ResourceServlet.java @@ -581,7 +581,16 @@ protected void doGet(HttpServletRequest httpServletRequest, HttpServletResponse (contentLength < 0 || contentLength > coreRequest.getConnectionMetaData().getHttpConfiguration().getOutputBufferSize())) { // send the content asynchronously - AsyncContext asyncContext = httpServletRequest.isAsyncStarted() ? httpServletRequest.getAsyncContext() : httpServletRequest.startAsync(); + AsyncContext asyncContext; + if (httpServletRequest.isAsyncStarted()) + { + asyncContext = httpServletRequest.getAsyncContext(); + } + else + { + asyncContext = httpServletRequest.startAsync(); + asyncContext.setTimeout(0); + } Callback callback = new AsyncContextCallback(asyncContext, httpServletResponse); _resourceService.doGet(coreRequest, coreResponse, callback, content); } diff --git a/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/ResourceServletTest.java b/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/ResourceServletTest.java index fbf1556c1aaa..127bb505ce10 100644 --- a/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/ResourceServletTest.java +++ b/jetty-ee10/jetty-ee10-servlet/src/test/java/org/eclipse/jetty/ee10/servlet/ResourceServletTest.java @@ -19,17 +19,23 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.net.URLClassLoader; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.EnumSet; import java.util.List; import java.util.Objects; +import java.util.Timer; +import java.util.TimerTask; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -61,6 +67,8 @@ import org.eclipse.jetty.http.UriCompliance; import org.eclipse.jetty.http.content.ResourceHttpContent; import org.eclipse.jetty.io.ByteBufferPool; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.io.content.ByteBufferContentSource; import org.eclipse.jetty.logging.StacklessLogging; import org.eclipse.jetty.server.AllowedResourceAliasChecker; import org.eclipse.jetty.server.HttpConfiguration; @@ -3749,6 +3757,168 @@ public void testServeResourceAsyncWhileStartAsyncAlreadyCalled() throws Exceptio assertThat(filterCalled.get(), is(true)); } + @Test + public void testServeResourceAsyncNoTimeout() throws Exception + { + // The OutputBufferSize must be smaller than the content length otherwise the request is not served async. + connector.getConnectionFactory(HttpConfiguration.ConnectionFactory.class).getHttpConfiguration().setOutputBufferSize(0); + + // Change the default async timeout to a short value, to avoid waiting the default 30 seconds. + // ResourceServlet should overwrite the async timeout to zero. + System.setProperty(ServletChannelState.class.getName() + ".DEFAULT_TIMEOUT", "100"); + try + { + ResourceServlet resourceServlet = new ResourceServlet(); + context.addServlet(resourceServlet, "/*"); + String text = "Test"; + Resource resource = new SlowResource(text.getBytes(UTF_8), 100); + resourceServlet.getResourceService().setHttpContentFactory(path -> new ResourceHttpContent(resource, "text/plain", ByteBufferPool.SIZED_NON_POOLING)); + + String rawResponse = connector.getResponse(""" + GET /context/ HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """); + HttpTester.Response response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.OK_200)); + assertThat(response.get(HttpHeader.CONTENT_LENGTH), is("" + text.getBytes(UTF_8).length)); + assertThat(response.getContent(), is(text)); + } + finally + { + System.clearProperty(ServletChannelState.class.getName() + ".DEFAULT_TIMEOUT"); + } + } + + private static class SlowResource extends Resource implements Content.Source.Factory + { + private final byte[] data; + private final long delayInMsBetweenBytes; + + public SlowResource(byte[] data, long delayInMsBetweenBytes) + { + this.data = data; + this.delayInMsBetweenBytes = delayInMsBetweenBytes; + } + + @Override + public Path getPath() + { + return null; + } + + @Override + public boolean isDirectory() + { + return false; + } + + @Override + public long length() + { + return data.length; + } + + @Override + public boolean isReadable() + { + return true; + } + + @Override + public boolean exists() + { + return true; + } + + @Override + public URI getURI() + { + try + { + return new URI("file:///slow-resource"); + } + catch (URISyntaxException e) + { + throw new RuntimeException(e); + } + } + + @Override + public String getName() + { + return "slow-resource"; + } + + @Override + public String getFileName() + { + return "slow-resource-filename"; + } + + @Override + public Resource resolve(String subUriPath) + { + return null; + } + + @Override + public Content.Source newContentSource(ByteBufferPool.Sized bufferPool, long offset, long length) + { + return new DelayingByteBufferContentSource(data, delayInMsBetweenBytes); + } + } + + private static class DelayingByteBufferContentSource extends ByteBufferContentSource + { + private final Timer timer = new Timer(true); + private final long delayInMsBetweenBytes; + private boolean delay = true; + + public DelayingByteBufferContentSource(byte[] data, long delayInMsBetweenBytes) + { + super(splitIntoByteBuffers(data)); + this.delayInMsBetweenBytes = delayInMsBetweenBytes; + } + + private static Collection splitIntoByteBuffers(byte[] data) + { + List buffers = new ArrayList<>(); + for (int i = 0; i < data.length; i++) + { + buffers.add(ByteBuffer.wrap(data, i, 1)); + } + return buffers; + } + + @Override + public Content.Chunk read() + { + if (delay) + { + delay = false; + return null; + } + delay = true; + return super.read(); + } + + @Override + public void demand(Runnable demandCallback) + { + Runnable superDemand = () -> super.demand(demandCallback); + timer.schedule(new TimerTask() + { + @Override + public void run() + { + superDemand.run(); + } + }, delayInMsBetweenBytes); + } + } + public static class WriterFilter implements Filter { @Override diff --git a/jetty-ee11/jetty-ee11-servlet/src/main/java/org/eclipse/jetty/ee11/servlet/ResourceServlet.java b/jetty-ee11/jetty-ee11-servlet/src/main/java/org/eclipse/jetty/ee11/servlet/ResourceServlet.java index 26eb82f837ec..566d81a470c7 100644 --- a/jetty-ee11/jetty-ee11-servlet/src/main/java/org/eclipse/jetty/ee11/servlet/ResourceServlet.java +++ b/jetty-ee11/jetty-ee11-servlet/src/main/java/org/eclipse/jetty/ee11/servlet/ResourceServlet.java @@ -593,7 +593,16 @@ protected void doGet(HttpServletRequest httpServletRequest, HttpServletResponse (contentLength < 0 || contentLength > coreRequest.getConnectionMetaData().getHttpConfiguration().getOutputBufferSize())) { // send the content asynchronously - AsyncContext asyncContext = httpServletRequest.isAsyncStarted() ? httpServletRequest.getAsyncContext() : httpServletRequest.startAsync(); + AsyncContext asyncContext; + if (httpServletRequest.isAsyncStarted()) + { + asyncContext = httpServletRequest.getAsyncContext(); + } + else + { + asyncContext = httpServletRequest.startAsync(); + asyncContext.setTimeout(0); + } Callback callback = new AsyncContextCallback(asyncContext, httpServletResponse); _resourceService.doGet(coreRequest, coreResponse, callback, content); } diff --git a/jetty-ee11/jetty-ee11-servlet/src/test/java/org/eclipse/jetty/ee11/servlet/ResourceServletTest.java b/jetty-ee11/jetty-ee11-servlet/src/test/java/org/eclipse/jetty/ee11/servlet/ResourceServletTest.java index 47e27c1eb9b2..e91d6fb64bac 100644 --- a/jetty-ee11/jetty-ee11-servlet/src/test/java/org/eclipse/jetty/ee11/servlet/ResourceServletTest.java +++ b/jetty-ee11/jetty-ee11-servlet/src/test/java/org/eclipse/jetty/ee11/servlet/ResourceServletTest.java @@ -19,17 +19,23 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.net.URLClassLoader; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.EnumSet; import java.util.List; import java.util.Objects; +import java.util.Timer; +import java.util.TimerTask; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -61,6 +67,8 @@ import org.eclipse.jetty.http.UriCompliance; import org.eclipse.jetty.http.content.ResourceHttpContent; import org.eclipse.jetty.io.ByteBufferPool; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.io.content.ByteBufferContentSource; import org.eclipse.jetty.logging.StacklessLogging; import org.eclipse.jetty.server.AllowedResourceAliasChecker; import org.eclipse.jetty.server.HttpConfiguration; @@ -3776,6 +3784,168 @@ public void testServeResourceAsyncWhileStartAsyncAlreadyCalled() throws Exceptio assertThat(filterCalled.get(), is(true)); } + @Test + public void testServeResourceAsyncNoTimeout() throws Exception + { + // The OutputBufferSize must be smaller than the content length otherwise the request is not served async. + connector.getConnectionFactory(HttpConfiguration.ConnectionFactory.class).getHttpConfiguration().setOutputBufferSize(0); + + // Change the default async timeout to a short value, to avoid waiting the default 30 seconds. + // ResourceServlet should overwrite the async timeout to zero. + System.setProperty(ServletChannelState.class.getName() + ".DEFAULT_TIMEOUT", "100"); + try + { + ResourceServlet resourceServlet = new ResourceServlet(); + context.addServlet(resourceServlet, "/*"); + String text = "Test"; + Resource resource = new SlowResource(text.getBytes(UTF_8), 100); + resourceServlet.getResourceService().setHttpContentFactory(path -> new ResourceHttpContent(resource, "text/plain", ByteBufferPool.SIZED_NON_POOLING)); + + String rawResponse = connector.getResponse(""" + GET /context/ HTTP/1.1\r + Host: local\r + Connection: close\r + \r + """); + HttpTester.Response response = HttpTester.parseResponse(rawResponse); + assertThat(response.toString(), response.getStatus(), is(HttpStatus.OK_200)); + assertThat(response.get(HttpHeader.CONTENT_LENGTH), is("" + text.getBytes(UTF_8).length)); + assertThat(response.getContent(), is(text)); + } + finally + { + System.clearProperty(ServletChannelState.class.getName() + ".DEFAULT_TIMEOUT"); + } + } + + private static class SlowResource extends Resource implements Content.Source.Factory + { + private final byte[] data; + private final long delayInMsBetweenBytes; + + public SlowResource(byte[] data, long delayInMsBetweenBytes) + { + this.data = data; + this.delayInMsBetweenBytes = delayInMsBetweenBytes; + } + + @Override + public Path getPath() + { + return null; + } + + @Override + public boolean isDirectory() + { + return false; + } + + @Override + public long length() + { + return data.length; + } + + @Override + public boolean isReadable() + { + return true; + } + + @Override + public boolean exists() + { + return true; + } + + @Override + public URI getURI() + { + try + { + return new URI("file:///slow-resource"); + } + catch (URISyntaxException e) + { + throw new RuntimeException(e); + } + } + + @Override + public String getName() + { + return "slow-resource"; + } + + @Override + public String getFileName() + { + return "slow-resource-filename"; + } + + @Override + public Resource resolve(String subUriPath) + { + return null; + } + + @Override + public Content.Source newContentSource(ByteBufferPool.Sized bufferPool, long offset, long length) + { + return new DelayingByteBufferContentSource(data, delayInMsBetweenBytes); + } + } + + private static class DelayingByteBufferContentSource extends ByteBufferContentSource + { + private final Timer timer = new Timer(true); + private final long delayInMsBetweenBytes; + private boolean delay = true; + + public DelayingByteBufferContentSource(byte[] data, long delayInMsBetweenBytes) + { + super(splitIntoByteBuffers(data)); + this.delayInMsBetweenBytes = delayInMsBetweenBytes; + } + + private static Collection splitIntoByteBuffers(byte[] data) + { + List buffers = new ArrayList<>(); + for (int i = 0; i < data.length; i++) + { + buffers.add(ByteBuffer.wrap(data, i, 1)); + } + return buffers; + } + + @Override + public Content.Chunk read() + { + if (delay) + { + delay = false; + return null; + } + delay = true; + return super.read(); + } + + @Override + public void demand(Runnable demandCallback) + { + Runnable superDemand = () -> super.demand(demandCallback); + timer.schedule(new TimerTask() + { + @Override + public void run() + { + superDemand.run(); + } + }, delayInMsBetweenBytes); + } + } + public static class WriterFilter implements Filter { @Override