Rebus.UnixFD (rebus v0.4.0)

View Source

Handles for Unix file descriptors.

Rebus hands every descriptor it receives to your code as a handle: in the unix_fds of a Rebus.call/3 reply, and in the unix_fds of a Rebus.Service.Call. A handle owns one open descriptor and closes it exactly once: when its owner calls close/1, when its owning process exits, or when the last reference to it is garbage collected, whichever comes first. A handle never exposes its descriptor number except inside with_fd/2, and once closed it invalidates the number, so a stale handle cannot reach an unrelated descriptor that the operating system has since given the same number.

Ownership

Ownership follows the controlling-process model of ports and sockets. Each handle has exactly one owning process. When the owner exits, for any reason including :kill, the handle closes at once, even if other processes still hold it, and reads closed for them too. A background thread then closes the descriptor, so a close that blocks cannot stall a scheduler; the same happens when a handle is garbage collected.

Any process holding a handle may reach the descriptor with with_fd/2, pass the handle in a message's fds:, or duplicate it with dup/1. Only the owner can close it with close/1 or pass ownership on with controlling_process/2. Each of the first three gives the holder, or the peer it sends to, a copy of the open file that outlives the handle's owner, so share a handle only with processes you trust with the file. owner/1 and open?/1 tell who owns a handle and whether it is still open:

{:ok, %Rebus.Message{unix_fds: [handle]}} = Rebus.call(conn, message)
{:ok, pid} = Rebus.UnixFD.owner(handle)   # pid == self()
:ok = Rebus.UnixFD.controlling_process(handle, worker)
true = Rebus.UnixFD.open?(handle)

Using the descriptor

with_fd/2 lends the raw number to a function and keeps the descriptor open until the function returns, even if the handle is closed meanwhile. Hand the number to something that takes its own copy, such as :socket.open/2, or open /dev/fd/N:

{:ok, {:ok, sock}} =
  Rebus.UnixFD.with_fd(handle, fn fd ->
    :socket.open(fd, %{domain: :local, type: :stream})
  end)

:ok = Rebus.UnixFD.close(handle)

Sending

A handle can be sent in the fds: of Rebus.Message.new/2 and the reply functions of Rebus.Service, by its owner or any other holder. Sending neither closes nor transfers it. The connection keeps the descriptor open until the kernel has taken it, even if the handle is closed or its owner exits meanwhile, and refuses one that is already closed with {:error, :unix_fd_closed}.

Distribution and platforms

A handle is local to the node that created it. One that has crossed to another node answers {:error, :not_local} to every operation there; back on its own node, it works again.

Handles need a small native library, which Rebus builds on Linux and macOS only when the application enables Unix fd support with config :rebus, unix_fds: true (see Enabling descriptor passing). Without it every function returns {:error, :unsupported}; supported?/0 tells which applies.

See Unix file descriptor passing for the full model.

Summary

Types

A raw descriptor number. Rebus borrows a raw descriptor that it sends: it never duplicates or closes it.

t()

A handle that owns one Unix file descriptor.

Functions

Closes a handle's descriptor.

Makes pid the owner of a handle.

Duplicates a handle's descriptor into a new handle owned by the caller.

Returns whether a handle is open.

Returns the process that owns a handle, or {:error, :closed} once it is closed.

Returns whether this build of Rebus supports handles, and so can pass descriptors.

Calls fun with the handle's raw descriptor number.

Types

error()

@type error() :: :not_local | :unsupported

raw()

@type raw() :: non_neg_integer()

A raw descriptor number. Rebus borrows a raw descriptor that it sends: it never duplicates or closes it.

t()

@opaque t()

A handle that owns one Unix file descriptor.

Functions

close(handle)

@spec close(t()) :: :ok | {:error, :closed | :not_owner | error()}

Closes a handle's descriptor.

Only the owner can close a handle; any other caller gets {:error, :not_owner} and the handle stays open. A closed handle answers {:error, :closed}, whoever asks, so calling this again is harmless.

The descriptor is closed when this returns :ok. Closing it runs on a dirty I/O scheduler, so if close(2) blocks, for instance on a socket with lingering data, only the caller waits.

If another process is inside with_fd/2 on the handle, or a connection is still sending it, the handle is closed at once but the descriptor stays open until that function returns or the send has passed it on.

:ok = Rebus.UnixFD.close(handle)
{:error, :closed} = Rebus.UnixFD.close(handle)

controlling_process(unix_fd, pid)

@spec controlling_process(t(), pid()) ::
  :ok | {:error, :closed | :noproc | :not_owner | error()}

Makes pid the owner of a handle.

Only the owner can transfer a handle; any other caller gets {:error, :not_owner}. A closed handle answers {:error, :closed}, whoever asks. If pid is not alive, the handle is closed and the call returns {:error, :noproc}. A pid on another node gets {:error, :not_local} and leaves the handle as it was. Making the current owner the owner again does nothing.

worker = spawn(fn -> receive do {:take, handle} -> use_and_close(handle) end end)
:ok = Rebus.UnixFD.controlling_process(handle, worker)
send(worker, {:take, handle})

dup(unix_fd)

@spec dup(t()) :: {:ok, t()} | {:error, :closed | error() | atom()}

Duplicates a handle's descriptor into a new handle owned by the caller.

The two handles refer to the same open file but close independently. The new handle is owned by the process that calls this, whether or not it owns the original. Returns {:error, :closed} if the handle is closed, or {:error, posix} if the operating system cannot duplicate the descriptor.

{:ok, mine} = Rebus.UnixFD.dup(borrowed)

open?(handle)

@spec open?(t()) :: boolean()

Returns whether a handle is open.

A handle that is not local to this node, or on a platform without handle support, is reported as not open.

owner(unix_fd)

@spec owner(t()) :: {:ok, pid()} | {:error, :closed | error()}

Returns the process that owns a handle, or {:error, :closed} once it is closed.

{:ok, owner} = Rebus.UnixFD.owner(handle)

supported?()

@spec supported?() :: boolean()

Returns whether this build of Rebus supports handles, and so can pass descriptors.

It is true on Linux and macOS when the application enabled Unix fd support with config :rebus, unix_fds: true in its config/config.exs. A library built on Rebus cannot enable it for its users, so one that needs descriptor passing can check this at startup and tell them to.

unless Rebus.UnixFD.supported?() do
  raise "add `config :rebus, unix_fds: true` to config/config.exs"
end

with_fd(handle, fun)

@spec with_fd(t(), (raw() -> result)) :: {:ok, result} | {:error, :closed | error()}
when result: term()

Calls fun with the handle's raw descriptor number.

Returns {:ok, result} with what fun returned, or {:error, :closed} without calling fun if the handle is closed.

The descriptor stays open until fun returns, even if the handle is closed or its owner exits meanwhile, and the number is valid only until then. Do not keep it, close it, or hand it to anything that outlives fun without taking its own copy, as :socket.open/2 does. To keep a descriptor of your own, use dup/1. If the calling process is killed inside fun, the descriptor is released when the process's memory is reclaimed. Exceptions, throws and exits from fun propagate after the descriptor is released.

{:ok, {:ok, contents}} = Rebus.UnixFD.with_fd(handle, &File.read("/dev/fd/#{&1}"))

Returning the number from fun, as in {:ok, fd} = with_fd(handle, & &1), gives you a number that may already be closed, or reused for something else, by the time you use it.