Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Servlet 3.1 ReadListener.onAllDataRead() is called twice under h2 or h2c if the server doesn't respond within 30s #8405

Closed
niloc132 opened this issue Aug 2, 2022 · 4 comments · Fixed by #10174
Assignees
Labels
Bug For general bugs on Jetty side

Comments

@niloc132
Copy link

niloc132 commented Aug 2, 2022

Jetty version(s)
tested 11.0.8, 11.0.11

Java version/vendor (use: java -version)

$ java -version
openjdk version "11.0.16" 2022-07-19
OpenJDK Runtime Environment (build 11.0.16+8)
OpenJDK 64-Bit Server VM (build 11.0.16+8, mixed mode)

OS type/version
Linux

$ uname -a
Linux runes 5.18.14-arch1-1 #1 SMP PREEMPT_DYNAMIC Sat, 23 Jul 2022 11:46:17 +0000 x86_64 GNU/Linux

Description
Steps to reproduce:

  • Run an h2 or h2c (can be reproduced with or without ssl) server
  • Host a filter or servlet that starts an async call
  • Add a read listener to the call, and validate that onAllDataRead is only hit once
  • Do not send a response from the server for 30 seconds - messages sent after this will have no effect.
  • Make a call from a client, and "half-close" the request (relying only on server streamed results when they are ready)

Expected behavior is that the onAllDataRead only happens once (immediately after the client half-closes), but the actual behavior is that the listener gets a second onAllDataRead call, 30s after the first one. Note that it seems likely that this value is configuration or platform dependent and if so, the note above about "do not send a response from the server for 30 seconds") likely also depends on this.

Verified with a web browser as a client (note that h2c isn't an option for web browsers, and the bug does not happen with http/1.1), and also with curl commands (with flags to force http2 and allow insecure localhost certs):

  • curl -k -XPOST https://localhost:8443/stream
  • curl --http2-prior-knowledge -XPOST http://localhost:8080/stream

How to reproduce?
Example servlet:

public class StreamServlet extends HttpServlet {
    private final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
        // Each time this method is called, create an async stream, and send out updates "periodically"

        AsyncContext asyncContext = req.startAsync();

        // Explicitly setting a long timeout, to ensure this isn't what is causing the 30s delay
        asyncContext.setTimeout(TimeUnit.MINUTES.toMillis(5));

        System.out.println("call started " + new Date());
        req.getInputStream().setReadListener(new ReadListener() {
            private final AtomicBoolean closed = new AtomicBoolean(false);
            @Override
            public void onDataAvailable() throws IOException {
                //ignore
            }

            @Override
            public void onAllDataRead() throws IOException {
                boolean wasOpen = closed.compareAndSet(false, true);
                if (!wasOpen) {
                    System.out.println("Already closed! " + new Date());
//                    throw new IllegalStateException("Already closed!");
                    return;
                }
                System.out.println("all data read " + new Date());
            }

            @Override
            public void onError(Throwable t) {
                t.printStackTrace();
            }
        });
        sendAsyncMessage(asyncContext, 3);
    }

    private void sendAsyncMessage(AsyncContext asyncContext, int count) {
        executorService.schedule(() -> {
            try {
                asyncContext.getResponse().getOutputStream().print("Hello\n");
                asyncContext.getResponse().getOutputStream().flush();

                if (count == 0) {
                    asyncContext.complete();
                } else {
                    sendAsyncMessage(asyncContext, count - 1);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }, 31, TimeUnit.SECONDS);//31 seconds to be slightly longer than the apparent 30s timeout
    }
}

A sample main() that enables both h2c and https, and the above servlet. This expects a app/index.html so a browser can experience the bug as well, also attached below. Note that if the SSL section is enabled, a keystore will be required.

public class Main {

    public static void main(String[] args) throws Exception {
        Server jetty = new Server();

        // https://www.eclipse.org/jetty/documentation/jetty-11/programming-guide/index.html#pg-server-http-connector-protocol-http2-tls
        final HttpConfiguration httpConfig = new HttpConfiguration();
        final HttpConnectionFactory http11 = new HttpConnectionFactory(httpConfig);

        {
            httpConfig.addCustomizer(new SecureRequestCustomizer(false));
            final HTTP2ServerConnectionFactory h2 = new HTTP2ServerConnectionFactory(httpConfig);
            final ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory();
            alpn.setDefaultProtocol(http11.getProtocol());

            // Configure the SslContextFactory with the keyStore information.
            SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
            sslContextFactory.setKeyStorePath("keystore.jks");
            sslContextFactory.setKeyStorePassword("secret");

            // The ConnectionFactory for TLS.
            SslConnectionFactory tls = new SslConnectionFactory(sslContextFactory, alpn.getProtocol());
            // The ServerConnector instance.
            ServerConnector connector = new ServerConnector(jetty, tls, alpn, h2, http11);
            connector.setPort(8443);
            jetty.addConnector(connector);
        }

        {
            HTTP2CServerConnectionFactory h2c = new HTTP2CServerConnectionFactory(httpConfig);
            ServerConnector connector = new ServerConnector(jetty, http11, h2c);
            connector.setPort(8080);
            jetty.addConnector(connector);
        }

        // serve contents from resources or jar
        final WebAppContext context = new WebAppContext(null, "/", null, null, null, new ErrorPageErrorHandler(), 0);

        URL index = Main.class.getResource("/app/index.html");
        context.setBaseResource(Resource.newResource(index.toExternalForm().replace("!/app/index.html", "!/")));

        // set up the stream servlet
        context.setContextPath("/");
        context.addServlet(StreamServlet.class, "/stream");
//        context.addFilter(new FilterHolder(new StreamFilter()), "/stream", EnumSet.noneOf(DispatcherType.class));

        jetty.setHandler(context);

        jetty.start();
        System.out.println("Server Started " + Arrays.toString(jetty.getConnectors()));
        jetty.join();
    }
}

Simple HTML file to load the stream and log messages as they arrive:

<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Document</title>
</head>
<body>
<script>
    function writeMessage(msg) {
        document.body.append(msg);
    }

    function handleMessage(reader) {
        reader.read().then(payload => {
            var done = payload.done;
            var value = new TextDecoder().decode(payload.value);
            document.body.append(value, document.createElement('br'));
            if (done) {
                document.body.append("Done.", document.createElement('hr'));
            } else {
                handleMessage(reader);
            }
        });
    }

    fetch('/stream', {method:'POST'}).then(result => {
        handleMessage(result.body.getReader());
    });
</script>

</body>
</html>
@niloc132 niloc132 added the Bug For general bugs on Jetty side label Aug 2, 2022
niloc132 added a commit to niloc132/deephaven-core that referenced this issue Aug 2, 2022
niloc132 added a commit to niloc132/deephaven-core that referenced this issue Aug 2, 2022
niloc132 added a commit to niloc132/deephaven-core that referenced this issue Aug 5, 2022
niloc132 added a commit to niloc132/deephaven-core that referenced this issue Aug 29, 2022
niloc132 added a commit to deephaven/deephaven-core that referenced this issue Sep 1, 2022
…calls to grpc (#2717)

Combined with #2401, the Jetty-based server can now directly offer grpc-web
(albeit without support for text payloads). While technically this works with
http/1.1 connections, it is best suited for h/2, so the JS API still defaults
to the existing websocket transport when https is not present.

See https://github.com/grpc/grpc/blob/v1.48.0/doc/PROTOCOL-WEB.md

Also includes a workaround for jetty/jetty.project#8405.

Fixes #1769
@joakime
Copy link
Contributor

joakime commented Jan 21, 2023

Looks like grpc added support for Jetty Servlet 4+ (javax and jakarta) but are hitting this as a bug.
See grpc/grpc-java#1621

@niloc132
Copy link
Author

It looks as though this is related to HTTP2Session.streamIdleTimeout, but still fails in a confusing way. If this value is set explicitly to zero/negative, then this bug no longer occurs. Note however (will document in a follow-up ticket) that it isn't possible to directly set this to zero/negative via AbstractHTTP2ServerConnectionFactory.streamIdleTimeout, as AbstractHTTP2ServerConnectionFactory.newConnection will not pass the value to the session unless it is greater than zero.

Setting the stream idle timeout to very large values will presumably just delay the time until the unexpected behavior takes place.

@sbordet
Copy link
Contributor

sbordet commented Jul 30, 2023

@niloc132 thank you for the detailed report, it is indeed a bug. Standby for a fix.

sbordet added a commit that referenced this issue Jul 30, 2023
…times out

Per Servlet semantic, HTTP/2 stream timeout should be ignored.

The code was trying to fail the read via `_contentDemander.onTimeout()`, but
then it was still calling `onContentProducible()`, which was returning `true`
because the state of the read was IDLE (all the request content was read) and
the request was suspended.

Now the code checks if the read was really failed; if it is not, then
`onContentProducible()` is not called and so the idle timeout is ignored.

Signed-off-by: Simone Bordet <simone.bordet@gmail.com>
@sbordet
Copy link
Contributor

sbordet commented Jul 30, 2023

@niloc132 I have a fix at #10174.

Would you be able to try it out and confirm the issue is solved for you?

sbordet added a commit that referenced this issue Jul 31, 2023
#10174)

* Fixes #8405 - onAllDataRead() is called twice under h2 if the stream times out

Per Servlet semantic, HTTP/2 stream timeout should be ignored.

The code was trying to fail the read via `_contentDemander.onTimeout()`, but
then it was still calling `onContentProducible()`, which was returning `true`
because the state of the read was IDLE (all the request content was read) and
the request was suspended.

Now the code checks if the read was really failed; if it is not, then
`onContentProducible()` is not called and so the idle timeout is ignored.

Signed-off-by: Simone Bordet <simone.bordet@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug For general bugs on Jetty side
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants