blob: 98b0d4899adcd3031fb0bc66c7385902e6da1ce5 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.catalina.core;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
import jakarta.servlet.AsyncContext;
import jakarta.servlet.AsyncEvent;
import jakarta.servlet.AsyncListener;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;
import static org.apache.catalina.startup.SimpleHttpClient.CRLF;
import org.apache.catalina.Context;
import org.apache.catalina.Wrapper;
import org.apache.catalina.connector.TestCoyoteAdapter;
import org.apache.catalina.startup.SimpleHttpClient;
import org.apache.catalina.startup.Tomcat;
import org.apache.catalina.startup.TomcatBaseTest;
/*
* Derived from a test for https://bz.apache.org/bugzilla/show_bug.cgi?id=63816
* Expanded to cover https://bz.apache.org/bugzilla/show_bug.cgi?id=63817 and
* additional scenarios.
*/
@RunWith(Parameterized.class)
public class TestAsyncContextStateChanges extends TomcatBaseTest {
@Parameterized.Parameters(name = "{index}: end [{0}], timing [{1}]")
public static Collection<Object[]> parameters() {
List<Object[]> parameterSets = new ArrayList<>();
for (AsyncEnd asyncEnd : AsyncEnd.values()) {
for (EndTiming endTiming : EndTiming.values()) {
parameterSets.add(new Object[] { asyncEnd, endTiming });
}
}
return parameterSets;
}
@Parameter(0)
public AsyncEnd asyncEnd;
@Parameter(1)
public EndTiming endTiming;
private ServletRequest servletRequest = null;
private AsyncContext asyncContext = null;
private AtomicBoolean failed = new AtomicBoolean();
private CountDownLatch servletStartLatch;
private CountDownLatch threadCompleteLatch;
private CountDownLatch clientDisconnectLatch;
private CountDownLatch endLatch;
private boolean dispatch;
@Test
public void testAsync() throws Exception {
dispatch = false;
servletRequest = null;
asyncContext = null;
// Initialise tracking fields
failed.set(true);
servletStartLatch = new CountDownLatch(1);
threadCompleteLatch = new CountDownLatch(1);
clientDisconnectLatch = new CountDownLatch(1);
endLatch = new CountDownLatch(1);
// Setup Tomcat instance
Tomcat tomcat = getTomcatInstance();
// No file system docBase required
Context ctx = getProgrammaticRootContext();
AsyncServlet bug63816Servlet = new AsyncServlet();
Wrapper wrapper = Tomcat.addServlet(ctx, "bug63816Servlet", bug63816Servlet);
wrapper.setAsyncSupported(true);
ctx.addServletMappingDecoded("/*", "bug63816Servlet");
tomcat.start();
Client client = new Client();
client.setPort(getPort());
// @formatter:off
client.setRequest(new String[] {
"GET / HTTP/1.1" + CRLF +
"Host: localhost:" + CRLF +
CRLF});
// @formatter:on
client.connect();
client.sendRequest();
// Wait for Servlet to start processing request
servletStartLatch.await();
if (asyncEnd.isError()) {
client.disconnect();
clientDisconnectLatch.countDown();
try {
endLatch.await();
} catch (InterruptedException e) {
// Ignore
}
} else {
client.setUseContentLength(true);
client.readResponse(true);
}
Assert.assertFalse(failed.get());
}
private static final class Client extends SimpleHttpClient {
@Override
public boolean isResponseBodyOK() {
return true;
}
}
private final class AsyncServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
servletStartLatch.countDown();
if (dispatch) {
return;
}
if (!asyncEnd.isError()) {
resp.setContentType("text/plain");
resp.setCharacterEncoding("UTF-8");
resp.setContentLength(2);
resp.getWriter().print("OK");
}
servletRequest = req;
asyncContext = req.startAsync();
asyncContext.addListener(new Listener());
if (!asyncEnd.isError()) {
// Use a short timeout so the test does not pause for too long
// waiting for the timeout to be triggered.
asyncContext.setTimeout(1000);
}
Thread t = new NonContainerThread();
switch (endTiming) {
case INLINE: {
t.run();
break;
}
case THREAD_COMPLETES_AFTER_SERVLET_EXIT: {
t.start();
break;
}
case THREAD_COMPLETES_BEFORE_SERVLET_EXIT: {
t.start();
try {
threadCompleteLatch.await();
} catch (InterruptedException e) {
// Ignore
}
endLatch.countDown();
break;
}
}
}
}
private final class NonContainerThread extends Thread {
@Override
public void run() {
if (endTiming == EndTiming.THREAD_COMPLETES_AFTER_SERVLET_EXIT) {
try {
/*
* As much as I dislike it, I don't see any easy way around this hack. The thread is started as the
* Servlet exits but we need to wait for the post processing to complete for the test to work as
* intended. In real-world applications this does mean that there is a real chance of an ISE. We may
* need to increase this delay for some CI systems.
*/
sleep(1000);
} catch (InterruptedException e) {
// Ignore
}
}
// Trigger the error if necessary
if (asyncEnd.isError()) {
try {
clientDisconnectLatch.await();
} catch (InterruptedException e) {
// Ignore
}
try {
ServletResponse resp = asyncContext.getResponse();
resp.setContentType("text/plain");
resp.setCharacterEncoding("UTF-8");
OutputStream os = resp.getOutputStream();
resp.setContentType("text/plain");
// Needs to be large enough to fill buffers and trigger
// the IOE for the test to work as expected.
for (int i = 0; i < 64; i++) {
os.write(TestCoyoteAdapter.TEXT_8K.getBytes(StandardCharsets.UTF_8));
}
} catch (IOException ignore) {
// Ignore - The test intends to trigger the IOE
}
}
if (endTiming != EndTiming.THREAD_COMPLETES_AFTER_SERVLET_EXIT) {
try {
switch (asyncEnd) {
case COMPLETE:
case ERROR_COMPLETE: {
asyncContext.complete();
break;
}
case DISPATCH:
case ERROR_DISPATCH: {
dispatch = true;
asyncContext.dispatch();
break;
}
case NONE:
case ERROR_NONE: {
break;
}
}
// The request must stay in async mode until doGet() exists
if (servletRequest.isAsyncStarted()) {
failed.set(false);
}
} finally {
if (endTiming == EndTiming.THREAD_COMPLETES_BEFORE_SERVLET_EXIT) {
threadCompleteLatch.countDown();
}
}
}
}
}
private class Listener implements AsyncListener {
@Override
public void onComplete(AsyncEvent event) throws IOException {
if (endTiming == EndTiming.INLINE) {
endLatch.countDown();
}
}
@Override
public void onTimeout(AsyncEvent event) throws IOException {
// Need to handle timeouts for THREAD_COMPLETES_AFTER_SERVLET_EXIT in the listener to
// avoid concurrency issues.
if (endTiming == EndTiming.THREAD_COMPLETES_AFTER_SERVLET_EXIT) {
switch (asyncEnd) {
case COMPLETE: {
asyncContext.complete();
break;
}
case DISPATCH: {
dispatch = true;
asyncContext.dispatch();
break;
}
default:
// NO-OP
}
}
if (servletRequest.isAsyncStarted() == asyncEnd.isNone()) {
failed.set(false);
}
endLatch.countDown();
}
@Override
public void onError(AsyncEvent event) throws IOException {
// Need to handle errors for THREAD_COMPLETES_AFTER_SERVLET_EXIT in the listener to
// avoid concurrency issues.
if (endTiming == EndTiming.THREAD_COMPLETES_AFTER_SERVLET_EXIT) {
switch (asyncEnd) {
case ERROR_COMPLETE: {
asyncContext.complete();
break;
}
case ERROR_DISPATCH: {
dispatch = true;
asyncContext.dispatch();
break;
}
default:
// NO-OP
}
if (servletRequest.isAsyncStarted() == asyncEnd.isNone()) {
failed.set(false);
}
endLatch.countDown();
}
}
@Override
public void onStartAsync(AsyncEvent event) throws IOException {
// NO-OP
}
}
public enum AsyncEnd {
NONE(true, false),
COMPLETE(false, false),
DISPATCH(false, false),
ERROR_NONE(true, true),
ERROR_COMPLETE(false, true),
ERROR_DISPATCH(false, true);
final boolean none;
final boolean error;
AsyncEnd(boolean none, boolean error) {
this.none = none;
this.error = error;
}
public boolean isNone() {
return none;
}
public boolean isError() {
return error;
}
}
public enum EndTiming {
INLINE,
THREAD_COMPLETES_BEFORE_SERVLET_EXIT,
THREAD_COMPLETES_AFTER_SERVLET_EXIT
}
}