jakartaee / servlet

Jakarta Servlet
https://eclipse.org/ee4j/servlet
Other
266 stars 85 forks source link

Differences in calling WriteListener.onError #433

Open lachlan-roberts opened 2 years ago

lachlan-roberts commented 2 years ago

If an async servlet is in a loop like:

    ServletOutputStream out = response.getOutputStream();
    out.setWriteListener(new WriteListener()
    {
        public void onWritePossible()
        {
            try
            {
                while (out.isReady())
                    out.write(someBuffer);
             }
             catch(Throwable t)
             {
                 t.printStackTrace();
             }     
        }

        public void onError(Throwable t)
        {
            t.printStackTrace();
        }
    });

and the client receiving the response closes the connection at some point, then we see different behaviors from Jetty, Tomcat and Undertow:

I'm not sure any of these behaviors is strictly correct:

gregw commented 2 years ago

@markt-asf @stuartwdouglas I'm not sure what the correct behavior is for this? Your thoughts?

markt-asf commented 2 years ago

I don't see a reason for onError() to depend on whether isReady() has been called. onError() should be triggered as early as practical. I'd expect isReady() to return false once isError() has been triggered. Generally, I think applications should implement AsyncListener. If the spec clarified that read/write never threw exceptions for non-blocking I/O and that applications should always use onError() that effectively means applications would be required to implement AsyncListener. Do we want to require that? I think we need to answer that question before we can decide on whether read/write and/or onError() see the exception.

gregw commented 2 years ago

@markt-asf there is a reason to think that isready is involved. The sync contract is that you will get a callback IFF isready had been called and returned false. That callback can be OWP or writeListener.onError.

Note that there is also AsyncListener.onError that can be used to report errors in between Io operations. But Io operations errors should go to the write listener at least, maybe both.

@lachlan-roberts can you test if AsyncListener.onError is also being called?

Perhaps the solution is that Write listener.onError is called IFF there is a pending callback from isReady, otherwise AsyncListener.onError is called.. unless exception can be thrown from write, flush or close?

Complex! But at least we can be precise about where an exception is reported and it will only be reported once

stuartwdouglas commented 2 years ago

Thinking about it I think that async write should never throw, and the result should always be passed to onError(). My reasoning is that say you pass a 10mb bufffer to write(), this will pretty much always need to be written out asynchronously, so there is always a possibility that the async write will fail after the write() operation, and onError has to be called. If we allow write() to throw then there are two possible ways this failure could be reported, which IMHO is not great.

gregw commented 2 years ago

@stuartwdouglas I'm OK with that interpretation (that write should not throw), but then there are still some issues:

stuartwdouglas commented 2 years ago
gregw commented 2 years ago

So how about this, in async mode:

stuartwdouglas commented 2 years ago

With regards to 'if there is an IO exception when there is no pending write, flush or close operation, then that is reported via AL.onError' I don't really like the thread safety implications, as you can basically be busy doing work that is not IO related at all, and then get an error callback running in another thread.

I guess this is no different to what happens already with timeouts, but I don't like that either :-)

gregw commented 2 years ago

I know what you mean. It is ugly having 3 onError methods. But if a stream is reset whilst there are no IO operations, then is an exception delivered to the WL, RL or AL?

Picking one is going to be intrinsically racey. Maybe the solution is to always deliver it to all of them?

markt-asf commented 2 years ago

For that specific instance I think I'd expect the exception on the AL with the WL and RL only seeing exceptions if further write/reads are attempted. ie treat it in a similar manner to timeout.

stuartwdouglas commented 2 years ago

Another alternative would be to delay until IO is attempted, which would match HTTP/1 semantics. That said I don't really like that approach, so I think delivering to the AL is probably the way to go.

gregw commented 2 years ago

So if I'm reading you both right.... in async mode:

I have quibbles with this, but no more or less than other suggestions. I don't think there is a perfect solution, so happy to go with a common OK solution.

stuartwdouglas commented 2 years ago

I was thinking a bit different:

So if I'm reading you both right.... in async mode:

* mode, write, flush & close may throw an Exception either for the current operation they are called for or for a previous exception if they are called after a previous exception reported either via AL.onError or WL.onError.

IMHO write, flush and close should never throw, we should have one place to handle errors and that should be the listener.

* if the IO operation associated with a write, flush or close (that didn't throw) fails, AND isReady() has not been called, then the exception is reported via AL.onError.

IMHO these should always be reported to the relevant write listener. In this case because we know the issue is related to a specific write operation then delivering to the write listener error handler makes sense. The isReady stipulation really does not make sense for errors in close().

* if the IO operation associated with a write, flush or close (that didn't throw) fails, AND isReady() has been called (and returned false), then the exception is reported via WL.onError and WL.onDataAvailable will never be called.

I think you mean onWritePossible, I agree except for the requirement to call isReady.

* if an IO operation has failed, then isReady() will return false. It will continue to return false even after either onError is called.

Makes sense.

I have quibbles with this, but no more or less than other suggestions. I don't think there is a perfect solution, so happy to go with a common OK solution.

gregw commented 2 years ago

IMHO write, flush and close should never throw, we should have one place to handle errors and that should be the listener.

yeah but no!!

There are two listeners we can report errors to, so there is no "the listener". I don't think it ever makes sense to always report to AL.onError (else why have WL.onError), but then it also doesn't work out to always report on WL.onError (but perhaps more sense???).

I too would like write/flush/close to never throw in async. Perhaps that can be achieved with always calling WL.onError?

Anyway, is my first proposal (3 days ago) better for you? If not, want to have a go at writing up what you think?

stuartwdouglas commented 2 years ago

I think the one from 3 days ago is good. I don't super like the idea of IO exceptions being delivered with no IO operations active but I think it is ok, and is really no different to what happens with timeouts.

gregw commented 2 years ago

This is from 3 days ago, but with the addition that write can throw if it is called after a previously reported exception.

In async mode:

stuartwdouglas commented 2 years ago

It looks like there is no circumstances where RL.onError can ever be called, due to the last point about it being reported through AL.onError.

I would say that if isReady on the ServletInputStream has returned false, the stream is still open (i.e. close has not been called and read() has not returned -1), and there is no pending write, flush or close operations then we should deliver to RL.onError.

I also don't like that as written if you attempt to read from the underlying socket, get an IO exception on the read, and there is a pending write operation then you will have to deliver the read exception to the write listener.

How about:

Write:

Read:

General:

markt-asf commented 2 years ago

I like a lot of this latest description. A couple of minor comments.

In Tomcat, an IOException during SIS.isReady will cause isReady to return false and RL.onError to be called. I think this is consistent with what is written above.

Read needs the same clarification that calling read or close if isReady has returned false will throw as per the standard isReady semantics. Generally, can we make the text for read and write as symmetrical as possible? At the moment it is hard to tell if the differences between them are differences in intended behaviour or just different phrasing.

stuartwdouglas commented 2 years ago

I think the Tomcat behavior you describe is consistent with what I have written above, even though it is not explicitly called out.

I don't think we need to clarify that read will throw after isReady returns false, as that is already part of the isReady contract. Also I don't think an InputStream close() should ever really throw. There has been no historical requirement to have isReady return true and IMHO I don't think it makes sense.

In terms of symmetry the text above was a bit of a brain dump. I think we are close to agreement so do you want me to write up a PR tomorrow and we can move the conversation there?

markt-asf commented 2 years ago

+1 to a PR,

lachlan-roberts commented 2 years ago

I have run the same test with also adding an AsyncListener directly before adding the WriteListener.

AsyncContext asyncContext = req.startAsync();
asyncContext.addListener(new AsyncListener(){...});
ServletOutputStream out = response.getOutputStream();
out.setWriteListener(new WriteListener()
{
    public void onWritePossible()
    {
        try
        {
            while (out.isReady())
                out.write(someBuffer);
         }
         catch(Throwable t)
         {
             t.printStackTrace();
         }     
    }

    public void onError(Throwable t)
    {
        t.printStackTrace();
        asyncContext.complete();
    }
});

Note that I have added a asyncContext.complete() call to WriteListener.onError(). Without this Jetty will never call AsyncListener.onComplete(), however it does not change the results of Tomcat and Undertow.

stuartwdouglas commented 2 years ago

Undertow will call the onError method if isReady returns false and then the connection fails while the data is being written out by the IO thread in the background. My thinking was that the exception should only be reported once, but the down side of the current behavior is you have to handle it in two different spots.

The version of Undertow we use in Quarkus (which is basically a fork of Undertow on top of Vert.x) will behave much more like the proposed spec text, with methods never throwing and all errors reported via the listeners.

lachlan-roberts commented 9 months ago

I have repeated this experiment after updating my test over Jetty/Tomcat/Undertow to what I believe are the latest servlet 6 versions. (Jetty-12.0.5, Tomcat-10.1.18, Undertow-2.3.10.Final)

The test is setup like this:

The client is continuously reading but aborts when it reads over 1024 * 1024 * 128 bytes. The server is continuously writing in a loop inside WriteListener.onWritePossible():

try
{
    while (outputStream.isReady())
    {
        outputStream.write(data.getBytes());
    }
}
catch (Throwable t)
{
    t.printStackTrace();
}

The results:

  1. Throwing from ServletOutputStream.write() inside onWritePossible().

    • Both Jetty and Undertow will always throw from the ServletOutputStream.write().
    • Tomcat will only sometimes throw, if it doesn't throw it will still calls onError after ServletOutputStream.isReady() returns false.
  2. The result of ServletOutputStream.isReady() after throwing from ServletOutputStream.write().

    • Jetty will return true, while Tomcat and Undertow will return false.
  3. When is WriteListener.onError() and AsyncListener.onError() called?

    • Jetty will always only call WriteListener.onError().
    • Tomcat will call both WriteListener.onError() then AsyncListener.onError().
    • Undertow will call neither.
  4. When is AsyncListener.onComplete() called?

    • Always called for Jetty and Tomcat, never called for Undertow.

Note: Even if the exception from ServletOutputStream.write() is caught and not re-thrown and outputStream.isReady() is not called, then Jetty and Tomcat still invoke the WriteListener.onError().

gregw commented 9 months ago

Thanks @lachlan-roberts. I'll comment a bit here about what think should be the behaviour and then discuss it in more detail in the PR review.

I see no problem with ServletOutputStream.write() throwing even when in async mode. The reason being that a call to WriteListener.onError should only ever be done if there has been a prior call to isReady() that returns false. It is entirely valid for isReady() to return true and then for a subsequent call to write to throw. I think all of the following are valid sequences:

  1. call to isReady() returns true
  2. call to write(...) throws
  3. call to isReady() returns true
  4. call to write(...) throws
  5. etc.

OR

  1. call to isReady() returns true
  2. call to write(...) returns normally
  3. call to isReady() returns false
  4. WriteListener.onError called
  5. call to isReady() returns true
  6. call to write(...) throws

I think that AsyncListener.onError(Throwable) should only be called if onWritePossible() throws, i.e. if it does not catch an exception thrown by write(...) or throws some other exception, then that will be passed to AsyncListener.onError(Throwable). But any exception passed to WriteListener.onError(Throwable) or thrown by write(...) but caught by the application, should not be passed to AsyncListener.onError(Throwable).

I think the following sequence is not valid:

  1. call to isReady() returns true
  2. call to write(...) returns normally
  3. call to isReady() returns true
  4. WriteListener.onError called

In short: