-
Notifications
You must be signed in to change notification settings - Fork 38.7k
Fix ByteBuffer Thread-Safety in WebSocket Binary Message Handling #35524
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
Fix ByteBuffer Thread-Safety in WebSocket Binary Message Handling #35524
Conversation
Replace direct ByteBuffer usage with asReadOnlyBuffer() in binary message sending to prevent concurrent modification issues when sharing buffers across multiple sessions. Signed-off-by: xeroman.k <[email protected]>
df5e918
to
9fdf8dc
Compare
Making the
But this will not prevent the original buffer from being written to, which will cause the data written to the outgoing websocket session to be garbled. In my opinion, this doesn't improve the situation with your use case about using the same bytebuffer in a concurrent fashion over several sessions. If anything, the |
Thank you for the feedback. Let me clarify the actual concurrency issue: The Real Problem: Position Race Conditions in Concurrent Access You're right that this isn't about data corruption. The issue is race conditions on ByteBuffer's position when multiple threads access it concurrently. What Actually Happens in Concurrent Execution:
// Multiple threads executing concurrently: // Inside sendBinary, each thread does: // Race condition example: Result: Some sessions receive partial data, some receive full data, depending on timing. This is Not About Sequential Processing The problem isn't that "position is at the end after first send." It's that during concurrent sends, threads interfere with each other's reading position, causing:
Why Position Independence Matters With asReadOnlyBuffer(), each thread gets its own position/limit/mark: WebFlux Already Solved This WebFlux recognized this exact issue:
High Concurrency is the New Normal With virtual threads and reactive patterns:
I don't think this is the correct pattern. Why Framework Should Handle This
The question isn't "should applications share ByteBuffers?" but rather "when they do (and they will), should the framework make it safe?" |
@bclozel |
@xeromank I'm still not convinced. The example you're pointing at for WebFlux is the other way around: those are buffers provided by the Framework to the application. If it was the case, then all WebFlux |
I think the reason webflux provide such buffers is ultimately because ByteBuffer exists internally, right? Actually, I don’t understand why WebFlux was designed that way. But at least, to use MVC WebSocket in an application, you have to use WebSocketMessage, and this model is provided by the framework. While it’s true that developers provide ByteBuffer to use BinaryMessage, wouldn’t it be natural to assume that internally, elements like position would be used in a thread-safe manner? |
Let's agree to disagree, then. |
Thanks for the quick feedback. |
And to add to that, I think someone will definitely have the same experience as me. And they’ll look at Spring and Tomcat code and think: ‘Why doesn’t it guarantee thread safety when using BinaryMessage?’ |
And does that mean we should use it like this?
I might be misunderstanding, but I think the following code represents a more typical usage pattern:
Because when you look at
That’s why I think the framework should guarantee thread safety at the framework level to prevent ByteBuffer position issues. |
@bclozel |
Let me have another look, I'll update you here. |
@bclozel |
I had another look at the 4 PRs, namely: #35517, #35518, #35519 and #35520. Now, focusing on the use case you have brought up, that can be summarized like this:
I agree that sending the same binary message is a bit tricky here, because you need to do something like this: // My broadcast pattern
ByteBuffer notification = ByteBuffer.wrap(data);
sessions.parallelStream().forEach(session ->
session.sendMessage(new BinaryMessage(notification.asReadOnlyBuffer()))
); That's assuming that the buffer won't be written to while messages are being sent. This is the very nature of Now I have considered a change to make that slightly easier, making the buffer read-only when we instantiate the A general pattern in Spring messaging to create immutable Here, the websocket message does not extend byte[] spring = "spring".getBytes(StandardCharsets.UTF_8);
BinaryMessage msg = new BinaryMessage(spring);
// mutating the spring byte array changes the message payload
ByteBuffer buffer = ByteBuffer.wrap("spring".getBytes(StandardCharsets.UTF_8));
BinaryMessage bufferMessage = new BinaryMessage(buffer);
// mutating the buffer changes the message payload Making the payload immutable without the developer's knowledge is likely to create regressions and this will be inconsistent compared to using raw byte arrays. In summary, I think we have considered many angles but we cannot change the behavior here. When dealing with |
Thank you so much for the detailed and thoughtful comment. I really appreciate the time you took to thoroughly review this. I completely agree that developers should understand the basics of ByteBuffer and byte[]. However, I don't think any developer would intentionally modify them after passing that information to BinaryMessage. Writing code with such structure would be extremely rare. My perspective isn't about modifying ByteBuffer or byte[] - it's about the position issues that can occur in multi-threaded environments when each server (e.g., Tomcat) extracts ByteBuffers from BinaryMessage for transmission, and multiple threads extract the same ByteBuffer concurrently. To resolve this, applications would need to create as many BinaryMessage instances as there are threads. But I think it's more efficient to internally create ByteBuffers that have different state values while pointing to the same byte array data, rather than requiring applications to handle this complexity. The core issue is that when the framework extracts the ByteBuffer for actual transmission, multiple threads sharing the same ByteBuffer instance will interfere with each other's position/limit/mark state, not that the underlying data gets modified. What are your thoughts on this perspective? This is the core of my thinking. |
You are making a valid point and I agree. As stated in my previous comment, I considered changing the existing implementation but decided against it because this would break existing behavior and would be inconsistent with all other message payloads. While you consider this a rare case, applying this change would make it impossible to access the payload in a mutable fashion, with no workaround possible. Other developers would say that reusing the same buffer in a concurrent manner is niche. It is a matter of perspective. If we get more similar feedback from the community we can reconsider but unfortunately in the meantime we will have to leave it at that. |
@bclozel |
You can extend it for your own purposes in your application. I don't think we're going to evolve the API for this reason. |
@bclozel I probably wouldn't have encountered this issue if I hadn't been using virtual threads either. Have a great day! |
Summary
This PR addresses a thread-safety issue when sending binary WebSocket messages to multiple sessions using a shared
ByteBuffer
. The fix ensures that each session receives an independent buffer view, preventing concurrent modification issues.Background
Following the previous PR discussion, this implementation takes a different approach by handling ByteBuffer safety at the session level rather than in
ConcurrentWebSocketSessionDecorator
.The Problem
When sending a
BinaryMessage
with the sameByteBuffer
to multiple WebSocket sessions, the buffer's position is shared across all send operations. This causes:Why This Matters
BinaryMessage
and ByteBuffer position sharing is not immediately obvious to developersThe Solution
Use
ByteBuffer.asReadOnlyBuffer()
in bothStandardWebSocketSession
andJettyWebSocketSession
to create independent buffer views for each send operation.Changes
StandardWebSocketSession.sendBinaryMessage()
: Usemessage.getPayload().asReadOnlyBuffer()
JettyWebSocketSession.sendBinaryMessage()
: Usemessage.getPayload().asReadOnlyBuffer()
Alignment with WebFlux
Spring WebFlux already implements this pattern:
WebFlux DataBuffer Implementations:
WebFlux WebSocket Sessions:
There's no reason why Spring MVC WebSocket should handle this differently than WebFlux. Both face the same thread-safety challenges, and consistency across the framework is important.
Testing
Added tests to verify:
Performance Impact
asReadOnlyBuffer()
creates a shallow copy (shares the underlying data)Conclusion
This change brings Spring MVC WebSocket in line with WebFlux's approach to ByteBuffer handling, providing better thread-safety by default while maintaining backwards compatibility and performance.