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
Conversation
…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.
…complete' handler
…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
…ts and liveclient to use now the 'http' lib
Here is a draft of what we discussed on the previous PR. |
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.
Thanks for that focused PR, I'll try to reach out @yohanboniface within the next couple of days to discuss it!
roll/websocket.py
Outdated
websocket.connection_open() | ||
wsprotocol = self.Protocol(websocket) | ||
request.transport.set_protocol(wsprotocol) | ||
return wsprotocol, websocket |
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 is the goal of returning wsprotocol
here?
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.
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.
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.
Alright, let's keep it as is.
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 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.
roll/websocket.py
Outdated
self.writer.close() | ||
|
||
|
||
class WebsocketHandler: |
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.
Maybe WebsocketProxy
would be a better name given the usage?
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.
Probably, even though both terminologies are probably equaly true here
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 was suggesting that because of the later: proxy = self.Protocols.get(protocol, None)
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 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): |
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 that still necessary?
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 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 = { |
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.
Can we move that to Roll
in order to be easy to customize?
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.
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') |
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 where the magic happens if I understand correctly, worth a comment :)
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.
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.
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 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?
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 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 |
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.
🤔
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.
Sorry, the backtracking between the 4 branches led to silly changes.
roll/websocket.py
Outdated
# 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 |
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 prefer the use of State.CLOSED
, see: https://github.com/pyrates/roll/pull/51/files#diff-92645e4dbf61c4340d8fdec66cc79a4fR75
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.
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
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. |
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.
Thanks for the changes!
roll/websocket.py
Outdated
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 |
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.
Why not raising a roll.HttpError
here?
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.
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.
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.
Maybe we could give errors their dedicated file to avoid that? @yohanboniface thoughts?
roll/websocket.py
Outdated
|
||
ws = self.switch_protocol(request) | ||
if 'websockets' not in request.app: | ||
request.app['websockets'] = set() |
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.
How do we ensure that set is flushed after some time? Is that even pertinent? (I have no experience with websockets)
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 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.
tests/test_websockets.py
Outdated
'Sec-WebSocket-Version': '13'}) | ||
|
||
assert ev.is_set() | ||
assert response.status == 101 |
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.
Can you use http.HTTPStatus.SWITCHING_PROTOCOLS
here and there for consistency?
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.
Done
tests/test_websockets.py
Outdated
await websocket.close_connection_task | ||
assert bdata == b'test' | ||
assert websocket.close_reason == '' | ||
assert websocket.state == 3 |
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 be State.CLOSED
, same for other tests.
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.
Done
tests/test_websockets_failure.py
Outdated
'Sec-WebSocket-Key': 'hojIvDoHedBucveephosh8==', | ||
'Sec-WebSocket-Version': '13'}) | ||
|
||
assert response.status == 426 |
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.
Same here with http.HTTPStatus.UPGRADE_REQUIRED
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.
Done
# Wrong upgrade | ||
with liveclient as query: | ||
response = await query('GET', '/ws', headers={ | ||
'Upgrade': 'http2', |
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.
Soon 😉
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 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() |
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 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? :)
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.
Pair programming sounds good. Whenever you want
…mplemented the new way, to prove it.
roll/__init__.py
Outdated
self.request.app.websockets.discard(self) | ||
await self.close() | ||
|
||
|
||
class Protocol(asyncio.Protocol): |
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 about calling it HTTPProtocol
?
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 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 |
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.
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, |
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.
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') |
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.
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 |
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.
"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
?
…us, overriden immediatly by the init of status.
…Moved the route lookup in the on_url
Superseded by #54 now merged \o/ |
No description provided.