pedestal

Sam Ferrell 2025-06-18T20:34:49.967289Z

I'm running into an issue (using 0.8.0-beta-1) with sending binary messages through a WebSocket channel created by io.pedestal.websocket/start-ws-connection . A message is delivered but its contents are empty. If I send the message directly to the RemoteEndpoint object the full binary message is delivered as expected. The message is a HeapByteBuffer.

(defn handle-ws-binary [uuid message]
  (let [data (deref sessions)]
    (if (uuid->room data uuid)
      (let [unpacker (MessagePack/newDefaultUnpacker message)
            max-keys (.unpackMapHeader unpacker)]
        (loop [idx 0]
          (if (< idx max-keys)
            (if (= (.unpackString unpacker) "dst")
              (let [dest (UUID/fromString (.unpackString unpacker))
                    chan (get-in data [:conns dest :chan])
                    endp (get-in data [:conns dest :session])]
                (when (some? endp)
                  (.close unpacker)

                  ;; Sends a binary message to the recipient but its contents
                  ;; are empty. The channel was created by
                  ;; io.pedestal.websocket/start-ws-connection
                  (async/>!! chan message)

                  ;; Sending the message directly works as intended with the
                  ;; full contents.
                  #_(.sendBinary (.getAsyncRemote endp) message)))
              (do (.skipValue unpacker)
                  (recur (inc idx))))))
        (.close unpacker)))))

hlship 2025-06-18T21:23:25.199259Z

Double checking that this is with Jetty, right?

Sam Ferrell 2025-06-18T21:23:47.005199Z

Jetty, correct

Sam Ferrell 2025-06-18T21:25:05.237999Z

I'm re-implementing it myself and the ByteBuffer seems to change when sending and receiving it over a channel, at least the lim gets set to 0.

hlship 2025-06-18T21:28:59.459869Z

That's where I was headed; something needs to "flip" the ByteBuffer so that Jetty is ready to read out of it what was originally written into it, if I remember this stuff correctly.

hlship 2025-06-18T21:29:20.595979Z

So I wonder if that's something Pedestal is not doing, that the native .sendBinary is doing?

hlship 2025-06-18T21:29:56.344929Z

(extend-protocol WebSocketSendAsync
  String
  (ws-send-async [msg ^RemoteEndpoint$Async remote-endpoint]
    (let [p-chan (async/promise-chan)]
      (.sendText remote-endpoint msg (send-handler p-chan))
      p-chan))

  ByteBuffer
  (ws-send-async [msg ^RemoteEndpoint$Async remote-endpoint]
    (let [p-chan (async/promise-chan)]
      (.sendBinary remote-endpoint msg (send-handler p-chan))
      p-chan)))
Looks similar though.

Sam Ferrell 2025-06-18T21:30:00.822929Z

Both methods end up calling .sendBinary, the only difference seems to me is that there is a channel involved

hlship 2025-06-18T21:30:20.849319Z

Pedestal is using .getAsyncRemote, though.

hlship 2025-06-18T21:30:32.407149Z

I need to check the tests to see if they properly exercise this code.

hlship 2025-06-18T21:34:46.105809Z

Tests look good AFAIK, definitely exercising ByteBuffers. But small ones, from byte arrays, not HeapByteBuffer. Not sure that makes a difference.

Sam Ferrell 2025-06-18T21:38:42.040819Z

If I try sending a duplicate via asReadOnlyBuffer it works 🤷

hlship 2025-06-18T21:42:54.965719Z

That's extra odd because asReadOnlyBuffer is a shallow copy, doesn't look like it changes position or limit.

hlship 2025-06-18T21:45:01.948019Z

The problem is that my tests to a .rewind after writing to the channel so they can compare what gets sent over the wire correctly. That may be functionality that's required is some way. Though that doesn't explain why things work for you hitting the APIs directly. it's not obvious what the difference is.

Sam Ferrell 2025-06-18T21:49:13.027379Z

Does something happen to the buffer when the handler for the binary message is finished?

Sam Ferrell 2025-06-18T21:50:00.552559Z

Rather, in this example, the socket is receving a binary message, and my code is forwarding it to another channel.

hlship 2025-06-18T21:50:02.090679Z

No, the BB is passed to .sendBinary and otherwise forgotten by Pedestal.

Sam Ferrell 2025-06-18T21:50:18.443489Z

Maybe GC'd?

hlship 2025-06-18T21:50:29.097919Z

Should be.

hlship 2025-06-18T22:00:57.654429Z

So maybe your calling code is in a loop? And it is reading into the buffer, passing it to Pedestal, then back to the loop, clearing/flipping the buffer, reading more data? That could create a race condition where, by the time Pedestal sees the BB and by the time the async Jetty stuff uses the buffer, it cleared and so nothing gets written out. The asReadOnlyBuffer insulates you from changes to position/limit of original BB but not from changes to the data inside the BB.

hlship 2025-06-18T22:02:51.576439Z

If that's the case, it's a problem ... Pedestal could to the asReadOnlyBuffer, but that's only part of the problem.

hlship 2025-06-18T22:05:19.587799Z

Looking at your code ... same ByteBuffer message sent to different channels, I think that's a problem in the calling code.

hlship 2025-06-18T22:06:21.185509Z

Docs should be specific that "ByteBuffer must not be modified by caller after writing into channel" and a reminder that "reading from a BB is a destructive operation". If this fits that scenario.

Sam Ferrell 2025-06-18T22:09:03.884509Z

My example has a bunch of unnecessary stuff happening, I'm able to reproduce the issue in this simpler one

(defn handle-ws-binary [uuid message]
  (let [data (deref sessions)
        chan (get-in data [:conns uuid :chan])]

    ;; Sends empty binary message
    #_(async/>!! chan message)

    ;; Sends full binary message
    (async/>!! chan (.asReadOnlyBuffer message))))

hlship 2025-06-18T22:09:18.287699Z

Ok, that's helpful.

hlship 2025-06-18T22:09:42.829979Z

Could you try using an async remote?

hlship 2025-06-18T22:09:48.720339Z

That's what the Pedestal code does.

Sam Ferrell 2025-06-18T22:10:20.237059Z

My re-implemented start-ws-connection does this

(defn start-ws-conn [session chan]
  (async/go-loop []
    (if-let [message (and (.isOpen session) (async/<! chan))]
      (do (condp instance? message
            String     (.sendText   (.getAsyncRemote session) message)
            ByteBuffer (.sendBinary (.getAsyncRemote session) message))
          (recur))
      (.close session))))

Sam Ferrell 2025-06-18T22:11:10.459749Z

Basically the same thing but with fewer moving parts so I could inspect what was happening

hlship 2025-06-18T22:13:50.393209Z

public Future sendBinary(ByteBuffer data) { What if you invoke .get on the returned java.util.concurrent.Future?

Sam Ferrell 2025-06-18T22:18:56.917409Z

Umm returns nil as described and the binary message is still empty (sending the non-duplicated one)

hlship 2025-06-18T22:20:52.235659Z

Ok, so does your start-ws-conn work? Are we looking for the difference between yours and Pedestal's?

hlship 2025-06-18T22:22:41.482349Z

io.pedestal.websocket/start-ws-connection uses the extra argument because it is running in the core.async thread pool and has to avoid doing an I/O, so it is asking Jetty to do the work async, and using the promise to know when the send completes w/o blocking (the way a future blocks).

Sam Ferrell 2025-06-18T22:22:56.215929Z

Both my start-ws-conn and Pedestal's start-ws-connection work when sending a duplicated ByteBuffer, neither work with the original buffer

hlship 2025-06-18T22:23:06.792629Z

Ok, that's useful to know.

hlship 2025-06-18T22:25:13.148069Z

And, again, the message is sent but empty.

Sam Ferrell 2025-06-18T22:25:42.640249Z

Yep exactly

hlship 2025-06-18T22:28:22.927599Z

Well, the LLM gods did research and say "yes you must make a read only copy" https://www.perplexity.ai/search/jetty-11-pass-a-bytebuffer-to-7Wd4ehb4SqeXPq4asgEVvw I'm still checking the references ...

hlship 2025-06-18T22:33:46.815929Z

So I'd love to see the code that is writing into your version of start-ws-conn's chan.

hlship 2025-06-18T22:34:26.046499Z

Because it gets back to the "are you reusing the ByteBuffer before Jetty has a chance to pipe it out on the web socket?"

hlship 2025-06-18T22:38:16.258269Z

The LLM references so far don't back up its conclusions, I'm thinking hallucination.

Sam Ferrell 2025-06-18T22:38:40.596199Z

Here is the whole server 😄

hlship 2025-06-18T22:40:03.215479Z

If it sounds like I'm dragging my feet on just adding .asReadOnlyBuffer, it's because that could potentially mask the problem without fixing it. The read-only BB will have its own mark/position/limit but will share the underlying byte array (for HeapByteBuffer) or stretch of memory (for direct) that is mutable.

hlship 2025-06-18T22:40:27.427859Z

That could cause much more damaging and hard to diagnose bugs later. For example, leaking data for one customer into a different customer.

Sam Ferrell 2025-06-18T22:40:56.202349Z

No, I think it makes more sense to figure out exactly what is happening the original buffer before blindly duplicating it. And the work-around works for my use-case for now so I'm not in any rush

Sam Ferrell 2025-06-18T22:42:28.172959Z

Thanks for all your help though! Really pleased with the changes to Pedestal so far 💯 let me know if theres any more information I can provide for this issue

🆒 1
hlship 2025-06-18T22:42:54.629509Z

I'm looking at your code, may take a little bit.

hlship 2025-06-18T22:44:05.667309Z

Issue with ByteBuffers is that they are mutable, and potentially very expensive to duplicate properly. If you start with a direct BB and make a heap BB copy of it, you've undone all the performance bonuses you get from a direct BB in the first place.

hlship 2025-06-18T22:52:51.655569Z

So ... in your code handle-ws-binary is passing the ByteBuffer messages it got from jetty websocket-land into the channel and back out to Jetty. But that's all async. So I'm going to peek at the code in Jetty that ultimately calls the handle-ws-binary function. Because I think that code may be looping, resetting the ByteBuffer etc., before the async code has a chance to kick in a short time later.

Sam Ferrell 2025-06-18T22:55:26.301109Z

It would make sense if Jetty was doing something to the buffer after the handler returns. And why a direct synchronous call to .sendBinary would work

hlship 2025-06-18T23:03:12.357049Z

> Developers should not continue to reference message objects of type http://java.io.Reader, java.nio.ByteBuffer or java.io.InputStream after the completion of the onMessage() call, since they may be recycled by the implementation.

Sam Ferrell 2025-06-18T23:12:22.918109Z

Should I prefer a synchronous call to .sendBinary here instead of using a duplicate?

hlship 2025-06-18T23:19:26.537839Z

No, I think what you want to do is make a proper, deep copy of the BB.

💯 1
hlship 2025-06-18T23:19:58.724889Z

But I'll beef up the Pedestal docs to make this very clear.

hlship 2025-06-18T23:22:00.217649Z

Deep copy: ByteBuffer/allocate a new buffer, then (.put new-bb message).

hlship 2025-06-18T23:59:34.721439Z

I'm thinking about making a change to web socket callbacks, to pass the "process object" to the on-text and on-binary callbacks.

👍 1
hlship 2025-06-20T18:12:56.357399Z

Ok, then. The code was already in that state, but the docs were not, which I've now fixed.