-
Notifications
You must be signed in to change notification settings - Fork 1.9k
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
Issue #3170 - WebSocket Proxy #3365
Issue #3170 - WebSocket Proxy #3365
Conversation
@lachlan-roberts this is a WIP PR, was that intended? I see the old proxy, have you decided to go with it rather than then enum+switch based one? |
@sbordet yes i was just trying out the feature of the WIP PR, I will go with the new WebSocketProxy class, I have further changes and new tests for it but I have not pushed them yet, still working out some issues with the tests. Some of the issues with the tests are fixed with the PR #3336, so it would be good to get that merged soon |
Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
- Introduced an EMPTY_SESSION in the ProxyFrameHandler as a terminal state to know whether a FailedCoreSession has been handled - Use while(true) loops to do the compareAndSet in ProxyFrameHandler - Improved the tests for the proxy so that it tests the frames received at every state (ie Client Proxy and Server) Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
- Redesigned the proxy into a new class called WebSocketProxy containing a Client2Proxy FrameHandler and a Server2Proxy FrameHandler - WebSocketProxy uses synchronized blocks with an enum state instead of the previous compare and sets - Created a new test similar to ProxyFrameHandlerTest to test the new WebSocketProxy Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
…nges only do state changes inside synchronized blocks, remember what action to do and only do this outside of the synchronized block Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
- Added test cases to test failures in and around the WebSocketProxy and how it handles them. - In WebSocketChannel.sendFrame() we were using a null cause for closeConnection, we are now extracting the cause from the AbnormalCloseStatus. This was resulting in onError not being called when there was actually an error and an AbnormalCloseStatus. Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
3eccc10
to
d6129f5
Compare
removing old versions of the proxy frame handler adding licence headers Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
d6129f5
to
3e33b35
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I started this review and made a lot of comments.... and then realised that most were based on a fundamental misunderstanding. So I have left them there for now as an indication of why I don't like the design. I thought there was a single proxy state protected my a synchronised lock... but there are two states protected by one locks? This is really strange as we grab the lock twice and change half the state each time... which opens the door for somebody else to grab the lock when we are half changed!!
I think there should be one lock and one proxy state - which does not try to fully duplicate the individual connection states of the two connections. The proxy state should be something like: CONNECTING, OPEN, CLIENT_CLOSING, SERVER_CLOSING, PROXY_CLOSING, CLOSED
@sbordet what do you think? have I got something wrong and am pushing @lachlan-roberts opposite to your instructions?
@Override | ||
public void succeeded() | ||
{ | ||
callback1.succeeded(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what if callback1.succeeded()
throws an unchecked exception?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@gregw then we need to review the whole Jetty codebase for such possibility.
@lachlan-roberts, I'm not keen having these 2 from()
methods - no other code uses them apart websocket code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@sbordet the from style is something we've been using in websockets. They are not used elsewhere because most of the code was written pre-lambda. While we might perhaps have too many from methods now, I do think we should at least consider if they are utility and are usable elsewhere.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the (Callback, Throwable) version is used 5 places in WebSocketProxy
and also 2 places in WebSocketChannel
.
the (Callback, Callback) one is used 4 times and only in WebSocketProxy
@Override | ||
public void failed(Throwable x) | ||
{ | ||
callback1.failed(x); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what if callback1 throws an unchecked exception?
@@ -66,10 +66,14 @@ | |||
/** | |||
* Async notification that Connection is being opened. | |||
* <p> | |||
* FrameHandler can write during this call, but will not receive frames until | |||
* the onOpen() completes. | |||
* FrameHandler can write during this call, but can not receive frames until the callback is succeeded. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not exactly correct.... it cannot receive frames until demand(int)
has been called with demand >0. This will be done automatically for non demandable FrameHandlers when the callback is succeeded. For Demandable handlers, it is an ISE to call demand prior to succeeding the onOpen callback
jetty-websocket/websocket-core/src/main/java/org/eclipse/jetty/websocket/core/FrameHandler.java
Show resolved
Hide resolved
public void onOpen(CoreSession coreSession, Callback callback) | ||
{ | ||
session = coreSession; | ||
System.err.println(name + " onOpen(): " + session); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps printlns should be converted to debugs
@Override | ||
public void onOpen(CoreSession session, Callback callback) | ||
{ | ||
System.err.println(toString() + " onOpen(): " + session); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
println to debug
break; | ||
|
||
case FAILED: | ||
failure = error; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should add t
as a suppressed exception to failure?
NOT_OPEN, | ||
CONNECTING, | ||
OPEN, | ||
ICLOSED, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is ICLOSED
really the same state for receiving a CLOSE
from either client2proxy
or proxy2server
?
It feels like we are forgetting vital info that could be useful for handling a subsequent error/timeout ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I also don't like the name ICLOSED
, at the very least it should be CLIENT2PROXY_ICLOSED
or CLIENT2PROXY_CLOSING
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ISHUT
and OSHUT
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@sbordet I'm not so sure. Why are we duplicating connection state in the proxy layer? I think we just need a single proxy state that captures what the proxy is currently doing and not what the state of it's connections are.
break; | ||
|
||
case ICLOSED: | ||
if (frame.getOpCode() == OpCode.CLOSE) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is wrong? When client2proxy
receives the close frame it's onFrame method will change to ICLOSED
and then send the close from using this method, so this will immediately go to CLOSED
.
I also don't like the double state change of grabbing lock and doing state handling in onFrame and then doing the same again in send. I think we should be able to grab lock once, do the state change, do the action and then any subsequent state change needed should be done in the callback. send(...)
is only called from onFrame
so all the state change logic can be done there.
Throwable failure = null; | ||
synchronized (lock) | ||
{ | ||
switch (state) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
likewise, connect
is only ever called from client2proxy.onOpen
so why does it need to grab the lock and check/change state again?
Oh I see... there are two states......
@gregw, this is a different approach based on states that I wanted @lachlan-roberts to code to figure out whether he liked it better, whether it was clearer to read, etc. Having said that, do you see actual problems with the current implementation? I ask because your comments seems more on the line of "I would have done it differently" rather than "there is an actual problem if this and that happen". The initial goal of this work, i.e. to have a proof that the core APIs allow to write a fully async proxy, seems satisfied to me. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@lachlan-roberts good job!
I am mostly complaining about the core APIs, which I think need some improvements in at least 2 places:
- handling of concurrent writes
- return type of the
connect()
method
I also think that onFrame()
has too many semantics; I would have preferred that close frames were delivered to a different method, so that it would have been easier to write handlers (now they all require - all the times - if (frame.getType() == CLOSE)
, and the callback object does 2 different things depending on the frame type.
@Override | ||
public void succeeded() | ||
{ | ||
callback1.succeeded(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@gregw then we need to review the whole Jetty codebase for such possibility.
@lachlan-roberts, I'm not keen having these 2 from()
methods - no other code uses them apart websocket code.
CompletableFuture<CoreSession> response = _client.connect(upgradeRequest); | ||
response.get(5, TimeUnit.SECONDS); | ||
clientHandler.sendText("hello world"); | ||
clientHandler.close("standard close"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should not be able to write the 2 lines above - sendText() + close()
- without a callback.
How do you avoid a tight loop sending frames?
for (int i = 0; i < 1000; ++i) {
session.sendFrame(frame, Callback.NOOP, false);
}
This should throw a WritePendingException
, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You'd think! That was also a surprise to me! But we definitely have code that allows multiple concurrent writes like this and there is no write pending exception! That is partially why we have the write flusher above the extension stack to serialise such parallel calls. If they happen in a bad order, then eventually a callback will be failed and the connection closed. So it is only in very simple use-cases that a framework/app can actually have multiple threads calling in this way, but we do allow it.
I'm not wedded to doing it this way, but it was how it was.... @lachlan-roberts can you check if this is a semantic private to our API, or does the JSR also allow such parallel writes without a WritePendingException?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@gregw the JSR allows you to do parallel writes in the same way
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@lachlan-roberts thanks! So yes I think we are stuck with the parallel call semantic. I don't think it costs us anything as we have the queue and flusher anyway.
WebSocketProxy.Server2Proxy proxyServerSide = proxy.server2Proxy; | ||
|
||
ClientUpgradeRequest upgradeRequest = ClientUpgradeRequest.from(_client, new URI("ws://localhost:8080/proxy/a"), clientHandler); | ||
CompletableFuture<CoreSession> response = _client.connect(upgradeRequest); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Returning here a CompletableFuture
is a wrong API because the Future
API can be used (and it is in the tests).
I would rather pass a Promise<CoreSession>
as parameter instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought we already had this discussion and decided that it was best to leave it this way with the CompletableFuture. Ultimately it really should just be the onOpen callback that is needed, but this is provided for backward compatibility and as a convenience.... even if it allows non async get() calls.
Happy to review this, but perhaps outside of the scope of this PR.
try (StacklessLogging stacklessLogging = new StacklessLogging(WebSocketChannel.class)) | ||
{ | ||
CompletableFuture<CoreSession> response = _client.connect(clientHandler, new URI("ws://localhost:8080/proxy/")); | ||
response.get(5, TimeUnit.SECONDS); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is so wrong 😃
You throw an exception from clientHandler.onOpen()
, but response.get()
does not throw and actually returns you back the session.
I don't think we want this behavior because there is no difference with the case where you don't throw from onOpen()
.
We definitely need to discuss this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as previously discussed this is a bit strange.... but if onOpen throws, then the connection is not opened and the future should not be succeeded... so get() should throw I thought? @lachlan-roberts ???
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@gregw If onOpen throws it is immediately caught in WebSocketChannel
and the callback is failed which will attempt a close. The CompletableFuture
will still be succeeded and return the CoreSession
(but the session should already be closed by the callback).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@lachlan-roberts is there a way that we can fail the CompletableFuture
if anything goes wrong up until the call to onOpen
returns? I'm not a huge fan of the CompletableFuture
, but I'm OK to let it remain as a backward compatible convenience... but it has to work reasonably and I think failing of failure is reasonable.
frame = proxyClientSide.receivedFrames.poll(); | ||
closeStatus = CloseStatus.getCloseStatus(frame); | ||
assertThat(closeStatus.getCode(), is(CloseStatus.SERVER_ERROR)); | ||
assertThat(closeStatus.getReason(), is("intentionally throwing in server onFrame()")); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is weird. proxyClientSide.receivedFrames
contains the frames that the client sent to the proxy. Why does it have a close frame with SERVER_ERROR
?
I would have expected that to be in proxyServerSide.receivedFrames
.
If this is the reply to the close frame sent by the server, then you should reorganize the test to do the assertions in order, with a bit of comment on what should happen.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm guessing this is because there is no good client side equivalent to SERVER_ERROR
. ie there is no CLIENT_ERROR
.
@Test | ||
public void testServerErrorClientNoResponse() throws Exception | ||
{ | ||
serverFrameHandler.throwOnFrame(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should be serverFrameHandler.noResponseOnFrame()
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this test was to test if what would happen in the Proxy if the server had an error and the client didn't respond to the close frame it was sent, so the client handler is overridden to not complete the close frame callback
NOT_OPEN, | ||
CONNECTING, | ||
OPEN, | ||
ICLOSED, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ISHUT
and OSHUT
.
if (failServer2Proxy) | ||
server2Proxy.fail(failure, callback); | ||
else if (failure != null) | ||
callback.failed(failure); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that if you end up in the default
case, you really want to fail the serverToProxy
as well.
If that's the case, the 2 failure cases become one and the code simplifies.
{ | ||
state = State.ICLOSED; | ||
closeCallback = callback; | ||
sendCallback = Callback.from(()->{}, callback::failed); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You should add a comment about why you store closeCallback
here - in 6 months from now you won't remember.
Note how the fact that you need to store the callback is because you know what the callback is doing, and you want to delay that action.
This means that the javadocs for onFrame()
should explain in great details what the callback is doing depending on the various frame types.
This is not ideal: you want different methods to have different semantics, not the same method having different semantics based on the frame type.
@sbordet I'm on board with the different approach of using states and synchronise blocks. I was already concerned that having two atomic states was almost certainly wrong... just we din't know how yet. However, my concerns are more than just "I would have done it differently". I am concerned that the approach of grabbing the lock in onFrame and then again in send needlessly allows for other actions to intervene between the two events. It should be sufficient to grab the lock once in onFrame, determine the action to be taken, change state, let the lock go and then attempt the action. All subsequent state changes should then be attempted in the callback success or failure of the action (of course grabbing the lock again). Having two states just needlessly increases the combinations and complexity of the state machine. Yes of courses it has to be full duplex and closure can come from both sides (or even spontaneously in the proxy), but I still don't think that needs dual states. Said another way, the |
@sbordet with regards to your comment about Core is rarely intended to be used directly like this, so keeping it simple and not wrong is more important than a little bit of convenience. |
Signed-off-by: lachan-roberts <lachlan@webtide.com>
Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
325c25b
to
37ab716
Compare
@lachlan-roberts are there any non proxy changes in this PR that should be merged prior to resolving the design of the proxy itself? |
The changes to Should I create a separate PR for these changes? |
yes |
…-3170-websocket-proxy Signed-off-by: lachan-roberts <lachlan@webtide.com>
…-3170-websocket-proxy Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
Signed-off-by: Lachlan Roberts <lachlan@webtide.com>
This is not perfect... but better in than out so it will be tested and maintained. So I think we should merge. @sbordet if you agree then accept changes? |
Implementation of a non-blocking
WebSocketProxy
using websocket-core as a proof of concept for issue #3170.WebSocketProxyTest
to test theWebSocketProxy
with various failures