TIL Using Erlang Ports

17 Apr 2019 · by Sophie DeBenedetto in Today I Learned

Erlang ports provide us an interface for communicating with external processes by sending and receiving messages. The Elixir Port module is built on top of Erlang’s ports and makes it easy to start and manage OS processes.

Creating a port to execute a given OS process can be done with the open/2 function:

cmd = "echo hello"
Port.open({:spawn, cmd}, [:binary])
# => #Port<0.5>

Here, we pass open/2 the :spawn tuple that contains the binary we want to execute over our port. The code above will execute echo hello on our OS for us.

So, why is this a useful tool?

It’s not too hard to imagine that you might have a program that needs to enact some bit of functionality for which Elixir is not well suited, or for which you already have a script written in some other language. Let’s say that our Elixir app needs to listen to changes in a particular directory and respond by executing some code. We want to leverage fswatch to listen for and report such changes. We can do so with the help of ports!

Starting a Process and Listening for Messages

We’ll use a port to start the fswatch process running. The Elixir process that opens the port is the owner of that port, and will receive messages from the port. Messages will be send from the port to the owner when the process running via the port puts anything to STDOUT.

We’ll define a module FsWatchAdapter, to open our port and receive messages from it. Our module will use GenServer so that it can receive messages from the port and act on them.

defmodule FsWatchAdapter do
  use GenServer

  def start_link(dir) do
    GenServer.start_link(__MODULE__, dir)
  end

  def init(dir) do
    state = %{
      port: nil,
      dir: dir
    }
    {:ok, state, {:continue, :start_fswatch}}
  end

  def handle_continue(:start_fswatch, state = %{dir: dir}) do
    cmd = "fswatch #{dir}"
    port = Port.open({:spawn, cmd}, [:binary, :exit_status])
    state = Map.put(state, :port, port)
    {:noreply, state}
  end

  def handle_info({port, {:data, msg}}, state) do
    IO.puts "Received message from port: #{msg}"
    {:noreply, state}
  end
end

Here, we start our GenServer with an argument of the directory we want to watch. We use the handle_continue/2 function to start fswatch over a port. Then we store the port in our GenServer’s state for later use.

Lastly, we define a handle_info/2 function that knows how to respond to the message that the GenServer process will receive from the port, when the fswatch process puts something to STDOUT.

Let’s see our code in action! You can test this out by

  • Copying and pasting the module into an iex console.
  • In iex:
iex> FsWatchAdapter.start_link("~/Desktop")
  • Create a new file, “testing-ports.txt” on your Desktop
  • You should see the following in the iex console:
Received message from port: "/Desktop/testing-ports.txt"

In order to terminate our fswatch process, we simply need to terminate our GenServer process. Since our FsWatchAdapter is the port owner, terminating it will terminate the process executing in the port in opened.

Conclusion

Ports are a convenient way to pass messages between your Elixir code and any external process. By leveraging GenServers, we can build a communication mechanism that allows our app to send, receive and respond to messages from external processes. You can learn more about Elixir ports here and more about Erlang ports here.

Sophie DeBenedetto

Sophie is an engineer and teacher at The Flatiron School. She loves teaching and learning and finding the Elixir School community felt like the perfect fit!