ring

2025-03-13T13:43:50.206049Z

Hi, I am working with Server Sent Events and using ring-jetty. I have a question about the way disconnections from the client are handled/detected. Right now if I keep the HTTP connection around (meaning I hold onto the OutputStream handed to me in the write-body-to-stream ) and the client disconnects I need to send 2 small events to get an IOException with a Broken pipe message. Note that I flush the OuputStream every event. It seems like the first event stays stuck in jetty's buffer until I try to send another one. Only then jetty tells me the connection is dead by throwing. I can get the exception on the first small event if I specify :output-buffer-size to a small number or if I send a big event. I believe touching jetty's buffer isn't a good idea since I imagine it would means having a small buffers for all HTTP responses... Is there a way to get the broken pipe exception on the first event regardless of the size without touching jetty's buffer size?

❤️ 1
weavejester 2025-03-13T13:51:49.507049Z

Could you explain what you mean by holding onto the OutputStream? Also, are you operating in synchronous or asynchronous mode?

2025-03-13T13:54:38.912499Z

I use asynchronous mode. By holding onto the output stream I mean keeping the output stream in an atom for later use, pushing events form the sever when they arrive. For some context I am maintaining the Clojure SDK for Datastar which relies on SSE. You helped me there a while back btw!

weavejester 2025-03-13T14:02:06.745229Z

I thought I recognized the name! Thanks for clarifying. It makes sense that this would be caused by some sort of buffering on Jetty's behalf, though it seems incorrect behavior - you'd expect Jetty to throw an error for any new data if the connection was closed. The third-party rj9a adapter uses Jetty 12. Would it be possible to check if this bug exists when using that? I ask because Jetty 11 is deprecated now (except for security updates), and it's just a matter of me finding the time to upgrade Ring's adapter.

weavejester 2025-03-13T14:02:55.940789Z

It should be a drop-in replacement. The project site is: https://github.com/sunng87/ring-jetty9-adapter

2025-03-13T14:04:37.334569Z

Well I can't try with rj9a because the it closes the output stream when the initial server thread it done processing even in async mode. It's this issue https://github.com/sunng87/ring-jetty9-adapter/issues/122

weavejester 2025-03-13T14:05:00.226859Z

Oh, well that's no good.

2025-03-13T14:06:16.695059Z

I tested with pedestal 7.2 however and the behavior is the same, on jetty 12 I believe. Pedestal uses a heartbeat under the hood which I'd like to avoid if possible.

weavejester 2025-03-13T14:07:11.450579Z

So upgrading Ring to 12 likely won't fix the underlying issue.

2025-03-13T14:07:53.904279Z

Although with pedestal it's worse since I detect a closed connection in 3 events, the first stays stuck, the second throws and the core.async channel gets closed and only then on the third event I get a closed channel and now the connection is broken...

2025-03-13T14:08:41.912469Z

Yes maybe there is a way to force jetty to flush and throw on the first event but I don't know how...

weavejester 2025-03-13T14:19:04.757699Z

I've had a quick poke around, but I haven't found anything in Jetty's issues, or a way of definitively testing whether a client has disconnected via the Servlet API.

2025-03-13T14:20:37.395439Z

Thanks for taking the time,

weavejester 2025-03-13T14:20:38.076219Z

Presumably you're testing a graceful TCP disconnection, not a sudden loss of connection.

weavejester 2025-03-13T14:21:16.445419Z

I guess you must be otherwise there'd be a timeout involved.

2025-03-13T14:21:40.327329Z

Ideally yes, http-kit does it on its own but since it's custom and eschew the servlet api, that's not a valid comparison

2025-03-13T14:22:19.881889Z

nor fair

weavejester 2025-03-13T14:23:02.825519Z

My suggestion would be to open an issue on the Jetty issues, but they may want a Java example so that they can confirm the bug. That sounds like something of a rabbithole, though.

weavejester 2025-03-13T14:26:31.980899Z

There is an isClosed method on the HttpOutput class that Jetty uses, though. You could try checking to see if the OutputStream for the Jetty adapter responds to the isClosed method.

weavejester 2025-03-13T14:27:33.647399Z

If it does, I wonder if that would give the correct answer regarding the OutputStream. Obviously it's not a very portable or future-proof solution, but it might be able to be used as part of a workaround in the Jetty adapter.

2025-03-13T14:27:45.409359Z

Yes I try that, basic java OutputSteam doesn't have this method I believe.

2025-03-13T14:28:41.131019Z

I'll check it out!

weavejester 2025-03-13T14:31:26.130379Z

It might also be being proxied by Ring in the async case (https://github.com/ring-clojure/ring/blob/dde9716e23f85497df4ea0deba148fd22ac99c6c/ring-jakarta-servlet/src/ring/util/jakarta/servlet.clj#L72), which may prevent direct access.

weavejester 2025-03-13T14:33:37.331519Z

Possibly you might try it with a synchronous handler first. Though I'd imagine that if writes are detecting the closed socket immediately, then I don't know if the isClosed method would.

2025-03-13T14:35:28.071619Z

the thing that bugs me is the buffering behavior. Tiny buffer or big event results in an execption first try. It is like jetty is saying "this event isn't big, I'll wait a bit to actually declare the connection is lost"

weavejester 2025-03-13T14:37:22.491619Z

The HttpOutput class appears to only set the closed state after a write has been completed (https://github.com/jetty/jetty.project/blob/24e5ec7ed04508c070f1fdb15898ecdbdf5a4b27/jetty-ee10/jetty-ee10-servlet/src/main/java/org/eclipse/jetty/ee10/servlet/HttpOutput.java#L218). It's weird that flush doesn't work.

2025-03-13T14:39:53.847639Z

It doesn't work only in this case. If the connection is alive I can flush tiny events without them getting stuck in jetty's buffer.

weavejester 2025-03-13T14:41:11.959629Z

It does sound like a bug with Jetty.

2025-03-13T14:41:39.650059Z

It does.

2025-03-13T14:45:16.813009Z

I'll investigate a bit further later. Maybe I'll try to get a repro working in java at some point and open an issue on the jetty repo. Thank you 🙏

weavejester 2025-03-13T14:45:38.259119Z

No problem. Sorry I couldn't help further.

❤️ 1
2025-03-13T14:46:21.126179Z

No problem, now I know I didn't miss something obvious in the docs!

wevrem 2025-03-13T15:58:23.904519Z

I just implemented SSE events with rj9a, so I'm an expert now, right? 😏🙄 The thing I found didn't work was if the body of write-body-to-stream returned immediately. So I couldn't have that body wrapped in go or future or something like that. I'm curious about that incident #122 because the body there is wrapped in a future, so I wouldn't think it would work at all. @jeremys what does your implementation of write-body-to-stream look like? Oh, one other thing. I implemented a heartbeat that sends ":heartbeat\n\n" (which is treated like a comment on the client side) and my connections are closing just fine when the next heartbeat comes along after client disconnect. And if I need to shut things down server-side, I close the channel that is feeding the output stream, and things shut down nicely.

1
2025-03-13T16:30:49.158219Z

As long as write-body-to-stream doesn't return the connection stays open. When it does return there are 2 scenarios according to the spec. If you run the server with :async? false the OutputStream will be closed. If you run with :async? true the server should leave it to you to close it. As far as I understand rj9a closes in both cases. It means to prevent the adapter closing the connection you need to do what you do, that is blocking in the write-body-to-stream function and not returning. It may be a valid way to do this if you use virtual thread. If not please don't I believe you'll end up blocking the whole thread pool. A heartbeat is a way to "force jetty" to take the closed connection into account. It works but at some point I'd like to figure out a way where I don't have to have one.

weavejester 2025-03-13T16:32:50.101719Z

Just to chime in and say that's all correct.

weavejester 2025-03-13T16:33:42.359699Z

In synchronous mode, the response is "complete" when the handler function returns. In asynchronous mode, it may not be.

2025-03-13T16:35:39.888379Z

Yes this makes sense

2025-03-13T16:36:29.280989Z

Also my specific implementation is there https://github.com/starfederation/datastar/blob/develop/sdk/clojure/adapter-ring/src/main/starfederation/datastar/clojure/adapter/ring/impl.clj You can check out the whole project here https://github.com/starfederation/datastar/tree/develop/sdk/clojure Not to plug my thing here but if you are into html over the wire Datastar is really nice.

💯 2