Skip to content
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

Integrating websockets directly to the core, using a new route proxy mechanism. #53

Closed
wants to merge 30 commits into from

Conversation

trollfot
Copy link
Member

No description provided.

…ntation adapted to Roll. Took the liberty to move the protocols to a new file, for more readability.
…transport reference to the request. Fixed subprotocols selection.
…proved the ws test file, just to prove it works. Now it needs testing.
…ently, the websocket closure, ws.close in the websocket handler is hanging a bit. Still investigating.
…ncy on aiohttp, which is unfortunate. Reverted back to the old testing.Transport for the other tests.
…the pipe or not ? Added some more test. One is currently being worked on to determine how to handle a faulty websocket.
…. Maybe there are alternative for the async HTTPClient
…ilure. As it turns out, the behavior of our failing websocket is normal. It seems Sanic's behavior is not right : a closing websocket shouldn't cut the communication. Decreased the websocket timeout from 10s to 5s. We might need a way to make it a bit more pluggable
…the closing of the comm channel, needs work. Tests fail currently
@trollfot
Copy link
Member Author

trollfot commented Mar 26, 2018

Here is a draft of what we discussed on the previous PR.
The websocket is now integrated directly to the 'route' decorator, adding a new 'protocol' keyword. That allows the route decorator to wrap the handler in a "proxy", if a proxy exists for the declared protocol. This proxy, in the case of the websocket, takes charge of the handshake and websocket protocol switch using directly 'set_protocol' on the transport.

Copy link
Contributor

@davidbgk davidbgk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for that focused PR, I'll try to reach out @yohanboniface within the next couple of days to discuss it!

websocket.connection_open()
wsprotocol = self.Protocol(websocket)
request.transport.set_protocol(wsprotocol)
return wsprotocol, websocket
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the goal of returning wsprotocol here?

Copy link
Member Author

@trollfot trollfot Mar 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is only just a semantic thing but it could be used when overridden. When i get a "switch_protocol", i expect to get the new protocol back, even if I don't use it directly. This can be removed obviously, if you don't share that feeling.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, let's keep it as is.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no longer an issue, we no longer have 2 distinct protocols. The proxy protocol (wsprotocol) is useless and only necessary if we were an extension. Now, it doesn't make sense, it was removed and only "websocket" remains. I should have realized that earlier.

self.writer.close()


class WebsocketHandler:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe WebsocketProxy would be a better name given the usage?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably, even though both terminologies are probably equaly true here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was suggesting that because of the later: proxy = self.Protocols.get(protocol, None)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed 'proxy' to 'handler', for consistency. This may be done the other way, if it's judged better over time.

roll/__init__.py Outdated


class Roll:
class Roll(dict):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that still necessary?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used it to store the websockets upon creation. If you mean : would it be better to have an attribute rather than a container logic, maybe ? I think it's rather nice to be able to store, for an extension, something in the app itself. It's probably out of scope, I can remove it.

roll/__init__.py Outdated
@@ -382,9 +397,12 @@ def write(self, *args):


Route = namedtuple('Route', ['payload', 'vars'])
Protocols = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move that to Roll in order to be easy to customize?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, let's do this.

roll/__init__.py Outdated
def connection_made(self, transport):
self.writer = transport
except HttpParserUpgrade:
self.upgrade = self.request.headers.get('Upgrade')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the magic happens if I understand correctly, worth a comment :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it's just a way to have the requested upgrade somewhere. It's not used yet. the "Upgrade" header should contain "websocket" or "keep-alive, websocket", in case of a websocket. We should test it somewhere, probably in the proxy itself, but this wasn't discussed with you or Yohan, so maybe we can take a decision here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I get it, how is the request upgraded then?

Can you add a test with "keep-alive, websocket" at that point or is it useless until we actually use it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got confused in my comment, i meant the upgrade can contain a list of protocols, just like sub-protocols can be a list of subprotocol, separated with commas, ordered by preference.
I added tests and comments in the code and a RFC link, to be less messy than I am in my explanations. Sorry about that 👎

Right now, I changed to write the info on the request. That makes much more sense than on the protocol: that was really silly.

It is not really parsed, just read as it is. We trust the handler/proxy to do what is right with it, as I did on the websocket one (fairly naively).

from collections import namedtuple
from http import HTTPStatus
from io import BytesIO
from typing import TypeVar
from urllib.parse import parse_qs, unquote
from urllib.parse import unquote, parse_qs
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, the backtracking between the 4 branches led to silly changes.

# Received data. We refuse the data if the websocket is
# already closed. If the websocket is closing, this data
# might be part of the closing handshake (closing frame)
if self.websocket.state != 3: # not closed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sorry. I restarted the work from the very first PR and forgot to have that change from the later PRs. That's also why I removed the other branches in their entirety (except the 51, that contain other points.). I'll correct that right now

… state. The 'Protocols' dict, defining proxies for the different protocols has been put inside the Application class, so it's possible to override it.
…equest, as a commodity, contains the information in its 'upgrade' property, taken from the UPGRADE header (might need parsing or validation). Failure to find the upgrade info will result in a 426 status, UPGRADE REQUIRED. Added some comments to ease of the understanding of that part.
… from 'websockets'. With the correct flow, it now works directly.
…de if it's suitable or not and avoid hardcoding any decision in the route decorator
@trollfot
Copy link
Member Author

I think I covered most of the principal concerns. I'll let it be dissected by both of you now and won't touch it anymore until then.

Copy link
Contributor

@davidbgk davidbgk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the changes!

if request.upgrade != 'websocket':
# https://tools.ietf.org/html/rfc7231.html#page-62
# Upgrade needed but none was request or of the wrong type
response.status = HTTPStatus.UPGRADE_REQUIRED
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not raising a roll.HttpError here?

Copy link
Member Author

@trollfot trollfot Mar 27, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a silly cross import problem. WebsocketHandler is imported in the init.py file and thus importing from init will create a problem, that's why I used the response, available there.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could give errors their dedicated file to avoid that? @yohanboniface thoughts?


ws = self.switch_protocol(request)
if 'websockets' not in request.app:
request.app['websockets'] = set()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we ensure that set is flushed after some time? Is that even pertinent? (I have no experience with websockets)

Copy link
Member Author

@trollfot trollfot Mar 27, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler ensures that the websocket is removed from the set. In Sanic, to give an example, they keep all the connections in the same fashion but they clear it thanks to a task that verifies the timeouts. Here, I think I covered all in a try/catch. But there is a risk, if the global handler task is canceled. It could be fixed, if that's a problem, with a done_callback that would ensure that or a recurring task that would check for closed ws.

'Sec-WebSocket-Version': '13'})

assert ev.is_set()
assert response.status == 101
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use http.HTTPStatus.SWITCHING_PROTOCOLS here and there for consistency?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

await websocket.close_connection_task
assert bdata == b'test'
assert websocket.close_reason == ''
assert websocket.state == 3
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be State.CLOSED, same for other tests.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

'Sec-WebSocket-Key': 'hojIvDoHedBucveephosh8==',
'Sec-WebSocket-Version': '13'})

assert response.status == 426
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here with http.HTTPStatus.UPGRADE_REQUIRED

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

# Wrong upgrade
with liveclient as query:
response = await query('GET', '/ws', headers={
'Upgrade': 'http2',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Soon 😉

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You wish ! ;-)

roll/__init__.py Outdated
# This header should be parsed and maybe validated if we want
# to handle complex declarations, such as :
# Upgrade: HTTP/2.0, SHTTP/1.3, IRC/6.9, RTA/x11
self.request.upgrade = self.request.headers['UPGRADE'].lower()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about the pattern yet: seems we are mixing a bit the Protocol and the Request responsibilities.
I feel like the big picture is:
HttpProtocol => Upgrade received => close http response => set_protocol(WSProtocol).

What about a pair session so we can tackle this down at four hands? :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pair programming sounds good. Whenever you want

roll/__init__.py Outdated
self.request.app.websockets.discard(self)
await self.close()


class Protocol(asyncio.Protocol):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about calling it HTTPProtocol?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it, it makes plenty sense

requirements.txt Outdated
@@ -1,5 +1,6 @@
autoroutes==0.2.0
biscuits==0.1.1
httptools==0.0.10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump to 0.0.11 🎉

…rotocol to use it. Renamed Protocol to HTTPProtocol.
@@ -399,19 +540,26 @@ class Roll:
Response = Response
Cookies = Cookies

protocols = {
'websocket': WSProtocol,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If protocols are a thing now, I think adding 'http': HTTPProtocol might be pertinent here. Still very unsure, more something to discuss.

roll/__init__.py Outdated
@@ -268,7 +278,7 @@ def host(self):

class Response:
"""A container for `status`, `headers` and `body`."""
__slots__ = ('app', '_status', 'headers', 'body', '_cookies')
__slots__ = ('app', '_status', 'headers', 'body', '_cookies', 'websocket')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No more needed ;)

roll/__init__.py Outdated
@@ -338,7 +454,12 @@ def on_body(self, body: bytes):
# FIXME do not put all body in RAM blindly.
self.request.body += body

def on_headers_complete(self):
# Lookup the route requested
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Say why do not say what" is my comment mantra ;)
And if you thought a "what" comment was needed here, maybe it's because the naming is not good.
Suggestions: resolve_route, match_route ?

@yohanboniface yohanboniface mentioned this pull request Mar 30, 2018
@yohanboniface
Copy link
Member

Superseded by #54 now merged \o/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants