Send message, `send(pid, message)`, from controller to a LiveView process not possible? If so, why not?

I was trying out a pattern I haven’t tried before: sending a message from a regular API controller to a LiveView process.

See code below for some of the relevant code. In a nutshell. One route for LiveView showing a registration form. One route for handling POST requests from that form. On form submit an AJAX POST request is made from the LiveView. In case there are any validation errors the form controller tries to send a message to the LiveView. At this point that message is just a non-sense test message. The LiveView does not receive the message, however.

# Router
    live "/form", FormLive
    post "/form", FormController, :create

# PageLive
      <script>
        function handleSubmit(event) {
          event.preventDefault()
          const form = event.srcElement
          const formdata = new FormData(form)
          const ajax = new XMLHttpRequest()
          ajax.open("POST", "/form", true)
          const crsfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
          ajax.setRequestHeader('x-csrf-token', crsfToken)
          ajax.send(formdata)
        }
      </script>

      def handle_info(:hello, socket) do
        IO.puts "RECEIVED" # Does not print after controller has sent the message
        {:noreply, socket}
      end

# RouteController
        # one or several validation errors
        %{"sender_pid" => sender_pid} = changeset.params
        IO.inspect sender_pid # "#PID<0.2900.0>" Note: String
        pid = String.slice(sender_pid, 5..-2)
        pid = pid(pid) |> IO.inspect # #PID<0.2900.0>
        send(pid, :hello)
        conn
        |> Plug.Conn.resp(406, "Not all form requirements have been met.")
        |> Plug.Conn.send_resp()

I hope this code provides sufficient context for my question: It seems I can’t send messages from my controller to the LiveView, why is that? I reckon I can use PubSub to get the job done, but I prefer to understand why the pattern I’m trying doesn’t work. I looked around in docs etc., but haven’t found a definite answer, yet.

What does Process.info(pid) return?

Can you show where sender_pid is captured? I’m not sure what other processes it could be picking up (a Cowboy acceptor maybe?) but it would explain what’s happening.

As a design question, can you elaborate on why you’re doing it this way instead of submitting the form into the live view process itself? This seems like a much more complicated way of achieving phx-submit.

2 Likes

I would also question the design choice
. But, putting this aside, where does your controller get the pid of the LV process from? Your code seems to suggest that the pid is sent as part of the form submit. If so, how does it work? It could be you’re just sending the message to the wrong pid.

The idea is this. LiveView form for registering users. Because it is a user register, and I need to set cookies, I must use a regular controller, not a LiveView, when submitting the form. However, I use a phx-change on the form to check for validation errors as the user is filling out the form.

When the user submits an invalid form, the controller handles it. Plug.Conn requires a response being sent back to the client, but I don’t want the page to reload, because it isn’t necessary. The current page just needs to be send some info about the errors. So I don’t render() or redirect() inside the controller in case of validation errors and I try to send a message to the LiveView process.

I have made some adjustments to the code. I was sending the pid as a string as part of the form data before. The controller then reconstructed a pid from that string. That was just a temporary solution. Now the LiveView registers self() under a name and the controller uses that name to get the right pid-address to send the message to.

Message to LiveView still does not get handled by handle_info.

Here the current code with returns of IO.inspect in comments. So this is the actual code, so names are different from original post.

# LiveView
defmodule TodayForumWeb.RegistrationLive do
  use TodayForumWeb, :live_view
  import Ecto.Changeset

  alias TodayForumWeb.Router.Helpers, as: Routes
  alias TodayForumWeb.Endpoint
  alias TodayForum.Accounts
  alias TodayForum.Accounts.User

  def mount(_params, _session, socket) do
    case Process.whereis(:temp_name) do
      nil -> nil
      _ -> Process.unregister(:temp_name)
    end

    Process.register(self(), :temp_name)

    changeset = Accounts.change_user_registration(%User{})

    password_unmet_requirements =
      validations(changeset)
      |> filter_password_validations()
      |> filter_validation_texts()

    socket =
      assign(socket,
        changeset: changeset,
        password_unmet_requirements: password_unmet_requirements
      )

    {:ok, socket}
  end

  def handle_event("validate_password", %{"user" => attrs}, socket) do
    changeset =
      %User{}
      |> Accounts.change_user_registration(attrs)

    password_unmet_requirements =
      changeset.errors
      |> filter_password_errors()
      |> filter_error_texts()

    socket =
      assign(socket,
        changeset: changeset,
        password_unmet_requirements: password_unmet_requirements
      )

    {:noreply, socket}
  end

  def handle_info({:form_error, payload}, socket) do
    IO.puts "FORM_ERROR RECEIVED"
    IO.inspect(payload)
    {:noreply, socket}
  end

  def handle_info(_, socket) do
    IO.puts "SOME MESSAGE RECEIVED"
    {:noreply, socket}
  end

  def render(assigns) do
    ~H"""
    <h1>Register</h1>

    <.form
      let={f}
      for={@changeset}
      class="auth-form"
      novalidate
      phx-change="validate_password"
      onsubmit="handleSubmit(event)"
    >

      <script>
        function handleSubmit(event) {
          event.preventDefault()
          const form = event.srcElement
          const formdata = new FormData(form)
          const ajax = new XMLHttpRequest()
          ajax.open("POST", "/users/register", true)
          const crsfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
          ajax.setRequestHeader('x-csrf-token', crsfToken)
          ajax.send(formdata)
        }
      </script>

      <%= if @changeset.action do %>
        <div class="alert alert-danger">
          <p>Oops, something went wrong! Make sure your registration meets all the requirements listed below.</p>
        </div>
      <% end %>

      <%= hidden_input f, :sender_pid, value: inspect(self()) %>

      <div class="form-segment">
        <%= label f, :email %>
        <%= email_input f, :email, required: true %>
      </div>

      <div class="form-segment">
        <%= label f, :username %>
        <%= text_input f, :username, required: true %>
      </div>

      <div class="form-segment">
        <%= label f, :password %>
        <%= password_input f, :password, required: true %>
          <ul class="field-requirements-list">
            <%= for r <- @password_unmet_requirements do %>
              <li class="requirement"><%= r %></li>
            <% end %>
          </ul>
      </div>

      <div class="form-segment">
        <%= submit "Register", disabled: !Enum.empty?(@password_unmet_requirements) %>
      </div>

      <div>
        <%= link "Log in", to: Routes.user_session_path(Endpoint, :new) %> |
        <%= link "Forgot your password?", to: Routes.user_reset_password_path(Endpoint, :new) %>
      </div>
    </.form>
    """
  end

  defp filter_password_errors(errors) do
    Enum.filter(errors, fn
      {:password, _} -> true
      _ -> false
    end)
  end

  defp filter_error_texts(errors) do
    Enum.map(errors, fn
      {:password, {error_text, _validation_type}} -> error_text
    end)
  end

  defp filter_password_validations(validations) do
    validations[:password]
  end

  defp filter_validation_texts(validations) do
    Enum.map(validations, fn
      {_validation_type, validation_text} -> validation_text
    end)
  end
end

# controller
defmodule TodayForumWeb.UserRegistrationController do
  use TodayForumWeb, :controller
  import Ecto.Changeset
  alias TodayForumWeb.Router.Helpers, as: Routes
  alias TodayForum.Accounts
  alias TodayForum.Accounts.User
  alias TodayForumWeb.UserAuth

  def new(conn, _params) do
    changeset = Accounts.change_user_registration(%User{})

    password_unmet_requirements =
      validations(changeset)
      |> filter_password_validations()
      |> filter_validation_texts()

    render(
      conn,
      "new.html",
      changeset: changeset,
      password_unmet_requirements: password_unmet_requirements
    )
  end

  def create(conn, %{"user" => user_params}) do
    case Accounts.register_user(user_params) do
      {:ok, user} ->
        {:ok, _} =
          Accounts.deliver_user_confirmation_instructions(
            user,
            &Routes.user_confirmation_url(conn, :edit, &1)
          )

        conn
        |> put_flash(:info, "User created successfully.")
        |> UserAuth.log_in_user(user)

      {:error, %Ecto.Changeset{} = changeset} ->
        # password_unmet_requirements =
        #   changeset.errors
        #   |> filter_password_errors()
        #   |> filter_error_texts()

        pid = Process.whereis(:temp_name) |> IO.inspect() # #PID<0.6034.5>
        Process.info(pid) |> IO.inspect()
            # [
            #   registered_name: :temp_name,
            #   current_function: {:gen_server, :loop, 7},
            #   initial_call: {:proc_lib, :init_p, 5},
            #   status: :waiting,
            #   message_queue_len: 0,
            #   links: [#PID<0.462.0>],
            #   dictionary: [
            #     plug_unmasked_csrf_token: "L2mOf8IyDhWN41YAycnyHOck",
            #     "$initial_call": {Phoenix.LiveView.Channel, :init, 1},
            #     plug_csrf_token_per_host: %{
            #       secret_key_base: "WroRbuq4WzDg4O6RIVUsSxTUSvU9ybVwVq3X/brMP6ajlCcZVx86yZgmPx95mJ+1"
            #     },
            #     "$ancestors": [#PID<0.462.0>, #PID<0.454.0>, TodayForumWeb.Endpoint,
            #     TodayForum.Supervisor, #PID<0.406.0>],
            #     "$callers": [#PID<0.6029.5>]
            #   ],
            #   trap_exit: false,
            #   error_handler: :error_handler,
            #   priority: :normal,
            #   group_leader: #PID<0.405.0>,
            #   total_heap_size: 1597,
            #   heap_size: 987,
            #   stack_size: 12,
            #   reductions: 1318555,
            #   garbage_collection: [
            #     max_heap_size: %{error_logger: true, kill: true, size: 0},
            #     min_bin_vheap_size: 46422,
            #     min_heap_size: 233,
            #     fullsweep_after: 65535,
            #     minor_gcs: 0
            #   ],
            #   suspending: []
            # ]

        Process.alive?(pid) |> IO.inspect() # true

        payload = "the payload"
        send(pid, {:form_error, payload}) # never is received

        conn
        |> Plug.Conn.resp(406, "not all form requirements have been met")
        |> Plug.Conn.send_resp()
    end
  end

  defp filter_password_errors(errors) do
    Enum.filter(errors, fn
      {:password, _} -> true
      _ -> false
    end)
  end

  defp filter_error_texts(errors) do
    Enum.map(errors, fn
      {:password, {error_text, _validation_type}} -> error_text
    end)
  end

  defp filter_password_validations(validations) do
    validations[:password]
  end

  defp filter_validation_texts(validations) do
    Enum.map(validations, fn
      {_validation_type, validation_text} -> validation_text
    end)
  end

end

# user schema/struct
defmodule TodayForum.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  schema "users" do
    field :email, :string
    field :first_name, :string
    field :surname, :string
    field :username, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :naive_datetime
    has_many :posts, TodayForum.Posts.Post

    timestamps()
  end

  @doc """
  A user changeset for registration.

  It is important to validate the length of both email and password.
  Otherwise databases may truncate the email without warnings, which
  could lead to unpredictable or insecure behaviour. Long passwords may
  also be very expensive to hash for certain algorithms.

  ## Options

    * `:hash_password` - Hashes the password so it can be stored securely
      in the database and ensures the password field is cleared to prevent
      leaks in the logs. If password hashing is not needed and clearing the
      password field is not desired (like when using this changeset for
      validations on a LiveView form), this option can be set to `false`.
      Defaults to `true`.
  """
  def registration_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:email, :password, :username])
    |> cast_assoc(:posts)
    |> validate_required(:username)
    |> validate_email()
    |> validate_password(opts)
    |> insert_requirement_statements()
  end

  defp validate_email(changeset) do
    changeset
    |> validate_required([:email])
    |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "Must have the @ sign and no spaces.")
    |> validate_length(:email, max: 160)
    |> unsafe_validate_unique(:email, TodayForum.Repo)
    |> unique_constraint(:email)
  end

  # password requirements
  @password_length "Use min 12 and max 72 characters."
  @password_lower_case "Add at least one lower case letter."
  @password_upper_case "Add at least one upper case letter."
  @password_special_or_number "Add at least one special or numberical character."

  defp validate_password(changeset, opts) do
    changeset
    |> validate_required([:password])
    |> validate_length(:password, min: 12, max: 72, message: @password_length)
    |> validate_format(:password, ~r/[a-z]/, message: @password_lower_case)
    |> validate_format(:password, ~r/[A-Z]/, message: @password_upper_case)
    |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: @password_special_or_number)
    |> maybe_hash_password(opts)
  end

  defp insert_requirement_statements(changeset) do
    validations = traverse_validations(changeset, fn
      # Password requirements only
      {:length, [min: _, max: _, message: _]} ->
        {:length, @password_length}

      {:format, ~r/[a-z]/} ->
        {:format, @password_lower_case}

      {:format, ~r/[A-Z]/} ->
        {:format, @password_upper_case}

      {:format, ~r/[!?@#$%^&*_0-9]/} ->
        {:format, @password_special_or_number}

      {other, opts} ->
        {other, inspect(opts)}
    end)

    %{changeset | validations: validations}
  end

  defp maybe_hash_password(changeset, opts) do
    hash_password? = Keyword.get(opts, :hash_password, true)
    password = get_change(changeset, :password)

    if hash_password? && password && changeset.valid? do
      changeset
      # If using Bcrypt, then further validate it is at most 72 bytes long
      |> validate_length(:password, max: 72, count: :bytes)
      |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
      |> delete_change(:password)
    else
      changeset
    end
  end

  @doc """
  A user changeset for changing the email.

  It requires the email to change otherwise an error is added.
  """
  def email_changeset(user, attrs) do
    user
    |> cast(attrs, [:email])
    |> validate_email()
    |> case do
      %{changes: %{email: _}} = changeset -> changeset
      %{} = changeset -> add_error(changeset, :email, "did not change")
    end
  end

  @doc """
  A user changeset for changing the password.

  ## Options

    * `:hash_password` - Hashes the password so it can be stored securely
      in the database and ensures the password field is cleared to prevent
      leaks in the logs. If password hashing is not needed and clearing the
      password field is not desired (like when using this changeset for
      validations on a LiveView form), this option can be set to `false`.
      Defaults to `true`.
  """
  def password_changeset(user, attrs, opts \\ []) do
    user
    |> cast(attrs, [:password])
    |> validate_confirmation(:password, message: "does not match password")
    |> validate_password(opts)
  end

  @doc """
  Confirms the account by setting `confirmed_at`.
  """
  def confirm_changeset(user) do
    now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
    change(user, confirmed_at: now)
  end

  @doc """
  Verifies the password.

  If there is no user or the user doesn't have a password, we call
  `Bcrypt.no_user_verify/0` to avoid timing attacks.
  """
  def valid_password?(%TodayForum.Accounts.User{hashed_password: hashed_password}, password)
      when is_binary(hashed_password) and byte_size(password) > 0 do
    Bcrypt.verify_pass(password, hashed_password)
  end

  def valid_password?(_, _) do
    Bcrypt.no_user_verify()
    false
  end

  @doc """
  Validates the current password otherwise adds an error to the changeset.
  """
  def validate_current_password(changeset, password) do
    if valid_password?(changeset.data, password) do
      changeset
    else
      add_error(changeset, :current_password, "is not valid")
    end
  end
end

So this is the behaviour.

Sign up

Is it really that big of a deal to do a redirect after registering? Don’t you want to update the session and change the page, or are you planning to keep them on the registration page once they’ve registered? Or are you doing it just in case there is an error and you want to display that in the liveview? If that’s the case, why don’t you register the user in the liveview, display the error in case of error or redirect if successful.

1 Like

I’d suggest taking a look at trigger-action. It allows LV form handling to trigger a POST request once the form is validated, up until that everything is handled as normal using just the LV.

2 Likes

Well, it isn’t a big deal to have the page reload. I sometimes go out of my way to try something new/different, to be able to learn from the experience. If there is a reason messages cannot, or should not, be sent from a controller to a LiveView, I prefer to know why.

You can send message from anywhere to anywhere. Most likely you didn’t have the right pid. No one knows the pid except the LV process itself and its supervisor. How did you get the info to the controller?

1 Like

If registration was successful, the user is redirected (or that’s the plan at least). If registration is not successful, they stay in the LiveView. I could rerender the LiveView if registration failed and pass the changeset returned by the failed registration. That’s how I handled it before trying to send a message from the controller to the LiveView instead.

Just use a LiveView for the registration form? I was told that the registration page can be rendered as a LiveView, but that the registration cannot be handled by a LiveView. Since LiveViews cannot set the auth token cookies.

Thank you! That’s good to know.

Must have overlooked something then. I could have sworn it is the same PID, but then I know it makes sense to keep debugging.

That sounds promising, indeed. Thanks. I’ll look into it.

One thing to watch out for - mount gets called by two processes during normal operation:

  • in the Cowboy acceptor, with connected?(socket) false for the initial “dead view” render
  • in the channel handler, with connected?(socket) true for subsequent updates

Do you get a different result if you capture self in assigns instead of calling it directly here? I’m wondering if the change from being called from a different process isn’t getting picked up by the diff algorithm…

1 Like

Oh. I meant to remove that line, since it is from back when I passed the pid as a string to the server. But, yes, let me check.

# Inside mount
IO.inspect self()

# Inside assign()
assign(socket,
    pid: self()
)

# Inside controller
pid = Process.whereis(:temp_name)
IO.inspect pid

They log the same pid number. But when I render @pid inside the template I get this quick jump from one pid to another.

Pid

I reckon that is what you referred to in the first part of your message.

Haven’t thought of this behaviour as a possible explanation. Glad you mentioned it.

Oh, and the handle_info({:form_error, payload}, socket does get triggered if I send a message from within the LiveView itself, by the way. Thought I might mention that also.

FYI mix phx.gen.auth —live was announced at ElixirConf this year. I’m not sure if the code for it is available yet but that may provide some patterns that you can use here.

2 Likes

Pid registration in mount of LiveView.

One thing I didn’t see mentioned here is the danger factor. You’re firstly exposing a PID to the user (not really bad in itself) but also allowing the user to send a message to any PID in the system. This can crash processes and lead to at least DoS.

2 Likes

This is normal. LiveViews are rendered twice. The first time from the HTTP request, the second time from the websocket process.

Usually you’d wrap your registration call in a connected? conditional:

if connected?(socket) do
    case Process.whereis(:temp_name) do
      nil -> nil
      _ -> Process.unregister(:temp_name)
    end
end

As @Nicd notes though %{"sender_pid" => sender_pid} = changeset.params is pretty dangerous. You should consider encrypting this value.

1 Like

@benwilson512 @Nicd @axelson

Thank you for replies. I’m soaking in all the input. Appreciate all the lessons learnt during this project.