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

Implement WebSocket support based on Plug.Conn.Adapter.upgrade/3 #88

Merged
merged 9 commits into from Oct 31, 2022

Conversation

mtrudel
Copy link
Contributor

@mtrudel mtrudel commented Oct 3, 2022

This PR provides support for upgrading HTTP connections to WebSocket connections by use of the c:Plug.Conn.Adapter.upgrade/3 callback. WebSocket implementations are called the :cowboy_websocket API. This work is hoisted in large part directly from Phoenix's Phoenix.Endpoint.Cowboy2Handler. This work is the Plug.Cowboy analog of mtrudel/bandit#38

See phoenixframework/phoenix#5003 for an omnibus overview of the larger body of work that this PR is part of.

@mtrudel mtrudel changed the title Implement no-op support for Plug.Conn.Adapter.upgrade/3 Implement WebSocket support based on Plug.Conn.Adapter.upgrade/3 and Sock Oct 16, 2022
@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 16, 2022

@josevalim as requested, this work is done and ready for review! See phoenixframework/phoenix#5003 for an overview & merge proposal.

lib/plug/cowboy/conn.ex Outdated Show resolved Hide resolved
mix.exs Outdated
@@ -34,6 +34,7 @@ defmodule Plug.Cowboy.MixProject do
def deps do
[
{:plug, "~> 1.7"},
{:sock, "~> 0.3.0"},
Copy link
Member

Choose a reason for hiding this comment

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

What do you think of making this part of plug as Plug.Websocket instead? Not a fan of adding a dependency that is just a single behaviour when we already have plug for HTTP interfaces.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, I dropped a comment. We should not depend on Sock because we should not expose the Sock API but the Cowboy API. Then Sock sits on top!

Copy link
Contributor Author

@mtrudel mtrudel Oct 30, 2022

Choose a reason for hiding this comment

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

(I wrote this in the flurry earlier today but it never actually got sent)

Got it. Actually, maybe I don't :)

I follow you as far as removing Sock from this PR, and having Plug.Cowboy expose Cowboy WS handler API. That much is clear.

When you say 'Sock sits on top!', are you suggesting that a caller does something like:

def call(conn, opts) do
   ....
   # Assuming that MyApp.Socket is what you ultimately want to handle the WS
   upgrade_adapter(conn, :websocket, {Sock, {MyApp.Socket, opts}, connection_opts})
end

At which time the underlying server (say Cowboy) will call Sock via the Cowboy WS API, and Sock will then call onwards to MyApp.Socket via the Sock API?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, the goal is to have Sock.upgrade(conn, sock_options) and that will call upgrade_adapter for each adapter accordingly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Got it; Sock in this case would wrap the socket lifecycle starting from upgrade.

Only thing I'm not sure is how that would look in the first PR split from phoenixframework/phoenix#5030 before we introduce Sock. Pretty sure it would still need to be cowboy specific.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, let’s not worry about Sock in that PR yet. Let’s change the plug dispatch bits and I think figuring out the rest will be straightforward!

@josevalim
Copy link
Member

Hi @mtrudel!

Thanks for the PRs, I have started reviewing them and I will drop some feedback around. It may look a bit fragmented but we can go back to the Omnissue if necessary.

For this PR, I want to note that Cowboy will not expose the Sock API. The point is exactly that each webserver has a different API and unifying them can lead us to an unhealthy common ground, especially given Bandit is more expressive than Cowboy.

Therefore, the goal in adapter_upgrade(conn, :websockets, ARG) is for ARG to be anything adapter specific and the behaviour will be dictated by the adapter. Then Sock will implement the callbacks on top of each webserver API (which is what Phoenix does today),

@josevalim
Copy link
Member

To further clarify, for Cowboy the API will be:

adapter_upgrade(conn, :websockets, {handler, arg, opts})

Where handler will literally be the Cowboy handler, with no wrapping or indirection whatsoever. Cowboy will call the handler directly.

@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 29, 2022

To further clarify, for Cowboy the API will be:

adapter_upgrade(conn, :websockets, {handler, arg, opts})

Where handler will literally be the Cowboy handler, with no wrapping or indirection whatsoever. Cowboy will call the handler directly.

@josevalim I've implemented the required changes to Plug.Cowboy to move in this direction, though it's not ending up as clean as we were hoping. As a result, I haven't pushed them to this branch since I'm not sure if this is an avenue we'll want to end up pursuing. You can see the changes at master...mtrudel:plug_cowboy:plug_upgrade_no_sock

The primary problem is that we're not able to get Cowboy to call the handler directly; from the perspective of Cowboy, Plug.Cowboy.Handler is the handler, and I don't see a way of changing Cowboy's perspective on that. As a result, you will see that we still need to thinly wrap [1] whatever handler we're upgrading to.

Moreover, to me this really starts to muddle the waters about the overall structure of the stack.

If we're still having to do some sort of wrapping/adapting in here I don't really see the benefit to this change; it's just adding another layer of indirection of the exact kind that we iterated on removing in the earlier incarnations of Sock within Phoenix. Instead of having Plug.Cowboy 'pass through' the complexity of :cowboy_websocket, we end up with two layers in the stack that need to understand :cowboy_websocket, which seems like one too many.

Taking a step back, perhaps this would hang together more cleanly with @ericmj's suggestion above, namely moving the Sock behaviour into Plug as Plug.WebSocket and broadening the purview of the Plug library to encompass WebSocket connections as well (we'd talked about this near the start of this whole workup).

Graphically, we'd be looking at something like the second option below:

Cowboy <--:cowboy_websocket--> Plug.Cowboy <--:cowboy_websocket--> Sock <--Sock API--> Phoenix [2]
vs. 
Cowboy <--:cowboy_websocket--> Plug.Cowboy <--Plug.WebSocket--> Phoenix

The benefit of the second approach is that the Sock library doesn't even need to exist at all! Less code & fewer abstractions all around, and a stackup that's consistent with the HTTP side of things. [3]

Really, I think it comes down to your concern that '[each] webserver has a different API and unifying them can lead us to an unhealthy common ground', and where that gets expressed in the stack. The unification of disparate server capabilities under a common Sock/Plug.WebSocket API has to happen somewhere in the stack, and having it at the adapter boundary has worked very well for Plug in the HTTP world (it's certainly the North Star that I've been building Bandit towards as a 'Plug/Sock native' implementation).

In any case, this is of course a PR against your library and thus this is obviously your call :). The changes on both this PR and the branch above are complete viz a viz tests / docs / &c so whichever direction you want to go, the work there is solid and ready to go pending merge of elixir-plug/plug#1119 and and update to the dependencies here.

Also, thanks once again for such timely and thoughtful reviews!

[1] Of note, this wrapping is a clean passthrough of the plain :cowboy_websocket behaviour, beyond the bit of mess to track its state via a tuple in our state & having to check for optional callbacks.

[2] This is the long-term plan. In the near term the Sock steps aren't present, but the fact remains that multiple layers in the stack still need to understand the :cowboy_websocket abstraction stands.

[3] This does preclude splitting phoenixframework/phoenix#5030 into two separate workups. If we went this way we'd also probably want to revisit the _adapter suffix on Plug.Conn.upgrade_adapter/3.

@josevalim
Copy link
Member

I see @mtrudel, thanks for exploring! Maybe it is worth sending a pull request to Cowboy, to additionally support {cowboy_websocket, req, handler, state, opts}, so we could also change the handler? Maybe it is something we could do ourselves if the protocol layer in Cowboy is pluggable?

@josevalim
Copy link
Member

Looking at the Cowboy code, I believe we could easily do it!

https://github.com/ninenines/cowboy/blob/master/src/cowboy_handler.erl#L41-L44

We just need to define a upgrade/5 function in a module somewhere, use that module as the first tuple element, and then delegate to cowboy_websocket passing the new handler.

@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 30, 2022

Looking at the Cowboy code, I believe we could easily do it!

https://github.com/ninenines/cowboy/blob/master/src/cowboy_handler.erl#L41-L44

We just need to define a upgrade/5 function in a module somewhere, use that module as the first tuple element, and then delegate to cowboy_websocket passing the new handler.

I agree. This looks like an easy workup that we can do outside of
Cowboy. I'm not sure if the relevant calls in cowboy_websocket would be considered private, however (they're exported, but not documented).

@josevalim
Copy link
Member

upgrade/5 is part of the contract and cosboy_websocket is explicitly part of the contract as we can return it, so I think we are good!

@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 30, 2022

Very good!

@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 30, 2022

The proposed upgrade/5 approach works as expected, and has been updated here. All tests / local validation is green.

A couple of things to note:

  • Because plug_cowboy no longer contains any code that runs within the websocket process (ie: within c:cowboy_websocket.websocket_init/1), it can no longer support the fullsweep_after option as passed in from Phoenix. Wiring this up will have to once again become a Phoenix responsibility in Implement WebSocket negotiation support based on Plug.Conn.upgrade_adapter/3 phoenixframework/phoenix#5030
  • We don't fully support passing through the :cowboy_websocket behaviour. Specifically, we don't support passing through c:cowboy_websocket.init/2 to the handler module. This is a bit of a conceptual discontinuity

Otherwise this should be good to go!

@josevalim
Copy link
Member

Because plug_cowboy no longer contains any code that runs within the websocket process (ie: within c:cowboy_websocket.websocket_init/1), it can no longer support the fullsweep_after option as passed in from Phoenix.

It actually has to be. fullsweep_after must be called in the socket process and we were calling it earlier on by mistake (where it did not have much of an effect).

We don't fully support passing through the :cowboy_websocket behaviour. Specifically, we don't support passing through c:cowboy_websocket.init/2 to the handler module. This is a bit of a conceptual discontinuity

To be clear, do you mean that we support all callbacks, except init? I think that's expected, because the init has already been "called" by Plug.

should handle the WebSocket connection, and must take the form `{handler, handler_opts,
connection_opts}`, where values are as follows:

* `handler` is a module which implements the `:cowboy_websocket` behaviour. Note that this
Copy link
Member

Choose a reason for hiding this comment

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

Let's add a link to cowboy_websocket?

Copy link
Contributor Author

@mtrudel mtrudel Oct 30, 2022

Choose a reason for hiding this comment

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

/me looks up how to link to Erlang behaviours in ex_doc lol

Copy link
Member

Choose a reason for hiding this comment

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

It has to be manual:

Suggested change
* `handler` is a module which implements the `:cowboy_websocket` behaviour. Note that this
* `handler` is a module which implements the [`:cowboy_websocket`](https://ninenines.eu/docs/en/cowboy/2.6/manual/cowboy_websocket/) behaviour. Note that this

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated

@@ -90,6 +90,10 @@ defmodule Plug.Cowboy.Conn do
:cowboy_req.inform(status, to_headers_map(headers), req)
end

@impl true
def upgrade(req, :websocket, args), do: {:ok, Map.put(req, :upgrade, {:websocket, args})}
Copy link
Member

Choose a reason for hiding this comment

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

We should probably validate here that args is a triplet, the first element is an atom and the third is a map.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was thinking about that at the dog park yesterday - maybe we should add a matching pattern match in the plug PR?

Copy link
Member

Choose a reason for hiding this comment

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

I think it is fine to raise here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated to explicitly match on a three element tuple

@josevalim
Copy link
Member

We are almost there! Just three minor comments left!

@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 30, 2022

Because plug_cowboy no longer contains any code that runs within the websocket process (ie: within c:cowboy_websocket.websocket_init/1), it can no longer support the fullsweep_after option as passed in from Phoenix.

It actually has to be. fullsweep_after must be called in the socket process and we were calling it earlier on by mistake (where it did not have much of an effect).

Yeah, I'm talking about after your recent fix to that in phoenixframework/phoenix#4933. In any case, it'll be in the right place now :)

We don't fully support passing through the :cowboy_websocket behaviour. Specifically, we don't support passing through c:cowboy_websocket.init/2 to the handler module. This is a bit of a conceptual discontinuity

To be clear, do you mean that we support all callbacks, except init? I think that's expected, because the init has already been "called" by Plug.

Yes, exactly.

@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 30, 2022

Other than a final change to the plug dependency herein, all done!

@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 31, 2022

PR updated to depend on 1.14.0. Note that this brings up a number of deprecation errors around push (what's happening with that, btw? I removed push support in bandit recently as a follow-on to it being deprecated in Plug).

Testing green locally; assume it will on CI now as well!

@mtrudel mtrudel changed the title Implement WebSocket support based on Plug.Conn.Adapter.upgrade/3 and Sock Implement WebSocket support based on Plug.Conn.Adapter.upgrade/3 Oct 31, 2022
@josevalim
Copy link
Member

what's happening with that, btw?

https://developer.chrome.com/blog/removing-push/

@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 31, 2022

what's happening with that, btw?

https://developer.chrome.com/blog/removing-push/

The funny thing is that push was probably the most difficult thing to get right in Bandit's HTTP/2 implementation. Had I not been such a completionist and skipped over it, I'd have saved a ton of time. Oh well 🙃

lib/plug/cowboy/conn.ex Outdated Show resolved Hide resolved
Co-authored-by: José Valim <jose.valim@gmail.com>
lib/plug/cowboy.ex Outdated Show resolved Hide resolved
Co-authored-by: José Valim <jose.valim@gmail.com>
@josevalim
Copy link
Member

Can you please update CI to use Elixir v1.10 and update the mix.exs to require ~> 1.10 (the same as Plug)?

@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 31, 2022

Yep, just saw that too. Coming right up

@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 31, 2022

Updated to the same min OTP/Elixir pair as Plug. Note that:

  • OTP actually went down to 21.3
  • I didn't touch the third 1.11.4/23.2.7 pair in the matrix

Let's see how CI likes that.

@josevalim
Copy link
Member

OTP actually went down to 21.3

Cowboy requires 22 IIRC.

@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 31, 2022

Updated to just change Elixir to 1.10, leaving OTP unchanged

@josevalim josevalim merged commit 0227c58 into elixir-plug:master Oct 31, 2022
@josevalim
Copy link
Member

💚 💙 💜 💛 ❤️

@mtrudel
Copy link
Contributor Author

mtrudel commented Oct 31, 2022

🥳

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

Successfully merging this pull request may close these issues.

None yet

3 participants