Rebus.Service (rebus v0.4.0)

View Source

Exports objects on a connection and answers the method calls made on them.

An object is a set of Rebus.Interface declarations exported at an object path. Each export names a handler process, which receives every call made on the export's interfaces as a message:

{export_ref, %Rebus.Service.Call{}}

The connection process never runs application code. It routes each inbound call, checks it against the declarations, and refuses anything that does not match with a standard D-Bus error, so a handler only ever sees calls to declared methods with arguments of the declared types. Calls to one handler arrive in the order they were received.

Example

calculator =
  Rebus.Interface.new!("org.example.Calculator",
    methods: [{"Add", in: [a: "i", b: "i"], out: [sum: "i"]}]
  )

{:ok, conn} = Rebus.connect(:session)
{:ok, ref} = Rebus.Service.export(conn, "/org/example/Calculator", [calculator])

receive do
  {^ref, %Rebus.Service.Call{member: "Add", body: [a, b]} = call} ->
    :ok = Rebus.Service.reply(call, [a + b])
end

Clients usually find a service by a well-known name. Export its objects first, then request the name with Rebus.Bus.request_name/4, so that no client reaches the name before the objects are there to answer.

Answering calls

Answer every call exactly once, with reply/3, reply_error/4 or reply_message/3. Any process holding the Rebus.Service.Call can answer it, so a handler can hand a slow call to another process, but the call stays bound to its export: when the export goes away, the call is failed.

A call whose caller asked for no reply has no_reply?: true. Answering it sends nothing and returns :ok, so a handler can treat every call the same way. For such a call that carries descriptors, the answer is also what releases them.

A second answer to the same call returns {:error, :unknown_call}, as does an answer to a call that has expired or whose export is gone. The caller never receives two replies.

What callers see

The connection answers these itself, each with a fixed message text that never echoes the call:

ConditionError name
Nothing exported at the path or below itorg.freedesktop.DBus.Error.UnknownObject
A path with exports only below it, for anything but Introspectorg.freedesktop.DBus.Error.UnknownInterface
Interface not exported on the objectorg.freedesktop.DBus.Error.UnknownInterface
Method not declared, or, without an interface, declared by no exported interface or by more than oneorg.freedesktop.DBus.Error.UnknownMethod
Argument signature differs from the declarationorg.freedesktop.DBus.Error.InvalidArgs
Property not declared by the interface a Properties call namesorg.freedesktop.DBus.Error.UnknownProperty
Set of a read-only propertyorg.freedesktop.DBus.Error.PropertyReadOnly
Get of a write-only propertyorg.freedesktop.DBus.Error.InvalidArgs
Set value of a type other than the declared oneorg.freedesktop.DBus.Error.InvalidArgs
:max_pending_calls calls already waitingorg.freedesktop.DBus.Error.LimitsExceeded
Handler exited with the call pendingorg.freedesktop.DBus.Error.Failed
Object unexported with the call pendingorg.freedesktop.DBus.Error.Failed
No answer within the export's :call_timeoutorg.freedesktop.DBus.Error.NoReply

A caller that asked for no reply gets none of these.

org.freedesktop.DBus.Peer is answered by the connection on every path, so it cannot be exported, and neither can org.freedesktop.DBus.Introspectable or org.freedesktop.DBus.Properties, which the connection reserves for itself.

Properties

The connection checks every org.freedesktop.DBus.Properties call against the declaration of the interface it names, and sends it to the handler that exported that interface. The call arrives with interface: "org.freedesktop.DBus.Properties", member "Get", "Set" or "GetAll", and the body as sent, whose first element names the interface. By then the interface is exported on the object, a Get or Set names a declared property that can be read or written, and a Set value has the declared type, so the handler only reads or writes values:

alias Rebus.Service.Call

receive do
  {^ref, %Call{member: "Get", body: [_iface, "Precision"]} = call} ->
    Rebus.Service.reply(call, [{"u", precision}])

  {^ref, %Call{member: "Set", body: [_iface, "Precision", {"u", value}]} = call} ->
    Rebus.Service.reply(call, [])

  {^ref, %Call{member: "GetAll"} = call} ->
    values = Rebus.Interface.wrap_properties!(settings, %{"Precision" => precision})
    Rebus.Service.reply(call, [values])
end

The call's :out_signature is "v" for Get, so the value is a {signature, value} variant; "" for Set; and "a{sv}" for GetAll, which Rebus.Interface.wrap_properties/2 builds from the declaration. A GetAll for an interface that declares no readable property is answered with an empty dictionary without reaching the handler.

Signals

emit/6 sends a signal from an exported object, typed by the interface declaration, and properties_changed/6 sends org.freedesktop.DBus.Properties.PropertiesChanged for properties that changed, as each property's emits_changed allows:

:ok = Rebus.Service.emit(conn, "/org/example/Calculator", calculator, "Computed", [5])

:ok =
  Rebus.Service.properties_changed(conn, "/org/example/Calculator", settings,
    %{"Precision" => 3}, [])

Introspection

The connection answers org.freedesktop.DBus.Introspectable.Introspect itself, with XML generated from the exported declarations, so tools such as busctl and d-feet can find and describe the objects. An exported object lists its own interfaces, then Peer, Introspectable and Properties, then the next path element of every export below it. A path with exports only below it, such as / or /org for /org/example/Calculator, lists Introspectable and those child elements, so a tool can walk the tree from /. A path served by a subtree export lists the subtree's interfaces and no children, since the connection cannot know which paths the handler serves.

Unix file descriptors

Descriptors sent with a call arrive in the call's :unix_fds as Rebus.UnixFD handles, indexed by its h arguments. The connection owns them and the handler borrows them while the call is pending: it may use them, with Rebus.UnixFD.with_fd/2, but only the connection can close them. When the call completes, however it completes, the connection closes each one it still owns, so a handler that ignores them leaks nothing. To keep them, take ownership with take_fds/2 before the call completes:

receive do
  {^ref, %Rebus.Service.Call{member: "TakeLog"} = call} ->
    {:ok, [handle]} = Rebus.Service.take_fds(call)
    :ok = Rebus.Service.reply(call, [])
    # handle is now the handler's, closed when the handler exits
    # or with Rebus.UnixFD.close/1
end

A reply sends descriptors through reply/3's :fds option, or the unix_fds of a message answered with reply_message/3: handles, raw descriptor numbers, or both, as for Rebus.Message.new/2. A reply may carry the call's own borrowed handles. Answering the call closes them, but the connection keeps their descriptors open until the reply has passed them on:

receive do
  {^ref, %Rebus.Service.Call{member: "Echo", unix_fds: [handle]} = call} ->
    :ok = Rebus.Service.reply(call, [0], fds: [handle])
end

See Unix file descriptor passing for the full ownership rules.

Summary

Types

A reason why export/4 refused an export.

A reason why a reply was not sent.

A reason why take_fds/2 did not return a call's handles.

Functions

Emits the signal member of interface from the object at path.

Exports interfaces at path on conn.

Emits org.freedesktop.DBus.Properties.PropertiesChanged for properties of interface on the object at path.

Answers a call with a method return carrying body.

Answers a call with a D-Bus error named name and carrying message.

Answers a call with a reply built by hand.

Takes ownership of the handles that arrived with a call.

Types

export_error()

@type export_error() ::
  :not_connected
  | :disconnected
  | :timeout
  | :remote_connection_unsupported
  | :invalid_path
  | :invalid_handler
  | :invalid_subtree
  | :invalid_call_timeout
  | {:invalid_interface, term()}
  | {:already_exported, path :: binary(), interface :: binary()}

A reason why export/4 refused an export.

{:invalid_interface, reason} carries the offending value, or {:duplicate, name} for an interface listed twice, or {:reserved, name} for an interface the connection implements itself.

reply_error()

@type reply_error() ::
  :unknown_call
  | :signature_mismatch
  | Rebus.Message.construction_error()
  | Rebus.error_reason()

A reason why a reply was not sent.

take_fds_error()

@type take_fds_error() :: :unknown_call | :timeout | :disconnected

A reason why take_fds/2 did not return a call's handles.

Functions

emit(conn, path, interface, member, body, opts \\ [])

@spec emit(
  pid(),
  binary(),
  Rebus.Interface.t() | binary(),
  binary(),
  [term()],
  keyword()
) ::
  :ok
  | {:error,
     :unknown_signal
     | :signature_required
     | {:invalid_option, term()}
     | Rebus.Message.construction_error()
     | Rebus.error_reason()}

Emits the signal member of interface from the object at path.

Given a Rebus.Interface, the signal must be one it declares, and body is encoded with the declared signature. Given an interface name, the signature must be passed as the :signature option. The signal is sent as Rebus.send/3 sends any message: the connection does not check that anything is exported at path.

Options

  • :signature - the body's signature. Required with an interface name, and refused with a Rebus.Interface, which supplies its own.
  • :destination - the unique name to send the signal to, rather than broadcasting it.
  • :flags and :fds - as for Rebus.Message.new/2.
  • :timeout - milliseconds to wait for the connection to accept the signal. Defaults to 5000.

Return values

  • :ok - the signal was handed to the socket.
  • {:error, :unknown_signal} - the interface does not declare member.
  • {:error, :signature_required} - an interface name without :signature.
  • {:error, {:invalid_option, opts}} - an option not listed above.
  • Any error of Rebus.Message.new/2, such as {:error, :invalid_body} for a body that does not fit the signature, or of Rebus.send/3.

export(conn, path, interfaces, opts \\ [])

@spec export(pid(), binary(), [Rebus.Interface.t()], keyword()) ::
  {:ok, reference()} | {:error, export_error()}

Exports interfaces at path on conn.

Calls on those interfaces at path are sent to the handler as {ref, %Rebus.Service.Call{}}, where ref is the reference returned here. Several exports can share a path, each with its own interfaces and handler, but an interface can be exported only once at a path.

The handler is monitored. When it exits, everything it exported is unexported, and each call still waiting for its answer is failed with org.freedesktop.DBus.Error.Failed. Exports also end with the connection.

Options

  • :handler - the local process that receives the calls. Defaults to the caller.
  • :subtree - when true, the export also serves every path below path, and the call's :path is the path actually called. An exact export of a path takes precedence over a subtree that covers it, and the longest covering subtree takes precedence over shorter ones. Defaults to false.
  • :call_timeout - positive milliseconds a call may wait for its answer before the connection answers the caller with org.freedesktop.DBus.Error.NoReply, or :infinity. Defaults to 60000.
  • :timeout - milliseconds to wait for the connection to accept the export. Defaults to 5000.

Return values

  • {:ok, ref} - the export's reference, which tags its calls and is what unexport/3 takes.
  • {:error, :invalid_path} - path is not a valid object path.
  • {:error, {:invalid_interface, reason}} - interfaces is not a non-empty list of distinct Rebus.Interface structs, or names an interface the connection reserves; see export_error/0.
  • {:error, :invalid_handler}, {:error, :invalid_subtree} and {:error, :invalid_call_timeout} - an invalid option. The handler must be a local PID.
  • {:error, {:already_exported, path, interface}} - interface is already exported at path.
  • {:error, :not_connected}, {:error, :timeout}, {:error, :disconnected} and {:error, :remote_connection_unsupported}

properties_changed(conn, path, interface, changed, invalidated, opts \\ [])

@spec properties_changed(
  pid(),
  binary(),
  Rebus.Interface.t(),
  %{required(binary()) => term()},
  [binary()],
  keyword()
) ::
  :ok
  | {:error,
     Rebus.Interface.property_error()
     | {:invalid_property_change, binary()}
     | {:invalid_option, term()}
     | Rebus.Message.construction_error()
     | Rebus.error_reason()}

Emits org.freedesktop.DBus.Properties.PropertiesChanged for properties of interface on the object at path.

changed maps property names to their new values, typed by the declaration, and invalidated names properties that changed without their values. Each property's emits_changed decides where it may appear, as Rebus.Interface.properties_changed/5 describes: one that emits true only in changed, one that emits :invalidates only in invalidated, and one that emits false or :const in neither.

Takes the options emit/6 takes, except :signature, and returns what it returns, plus Rebus.Interface.properties_changed/5's errors for a property the interface does not declare or does not allow where it appears.

reply(call, body, opts \\ [])

@spec reply(Rebus.Service.Call.t(), [term()], keyword()) ::
  :ok | {:error, reply_error()}

Answers a call with a method return carrying body.

The reply is encoded with the method's declared out-signature, the call's :out_signature, in the calling process, so a body that does not fit it is reported here as {:error, :signature_mismatch} and the call stays open for another answer.

Options

  • :fds - Unix file descriptors to send with the reply, which h values in body index: Rebus.UnixFD handles, raw descriptor numbers, or both, as for Rebus.Message.new/2. They may include the call's own borrowed handles, whose descriptors stay open until the reply is written. A handle the handler owns stays open and its own, and a raw number stays the handler's to close once this returns. Defaults to [].
  • :timeout - milliseconds to wait for the connection to accept the reply. Defaults to 5000.

Return values

Once the connection accepts a reply, the call is complete and the handles it borrowed are closed, even if writing the reply then fails. Their descriptors stay open only until the frame is written or abandoned, and the call cannot be answered again.

  • :ok - the reply was handed to the socket, or the caller asked for no reply and nothing was sent.
  • {:error, :signature_mismatch} - body does not fit the call's :out_signature.
  • {:error, :unknown_call} - the call was already answered, expired, or its export is gone.
  • {:error, :unix_fd_closed} - a handle in :fds was already closed, or belongs to another node, when the connection accepted the reply. Nothing was sent, and the call stays open.
  • {:error, :unix_fd_unsupported} and {:error, :unix_fd_not_negotiated}
    • the reply carries descriptors this connection cannot send, as for Rebus.send/3. The call stays open.
  • {:error, :unix_fd_send_failed} - the kernel refused a descriptor, so nothing was written and the caller gets no reply. The call is complete all the same and cannot be answered again.
  • {:error, :timeout} - delivery is ambiguous, as for Rebus.send/3. The call is complete either way and cannot be answered again.
  • {:error, :disconnected} - the connection stopped.
  • Any other reason is a failure to build or write the frame, as for Rebus.send/3. The call is complete unless the frame could not be built.

reply_error(call, name, message, opts \\ [])

@spec reply_error(Rebus.Service.Call.t(), binary(), binary(), keyword()) ::
  :ok | {:error, reply_error()}

Answers a call with a D-Bus error named name and carrying message.

name must be a valid D-Bus error name, such as "org.example.Error.Refused"; an invalid one returns {:error, {:invalid_header_field, :error_name}} and leaves the call open. Takes the same options and returns the same values as reply/3. The body has no h values, so descriptors passed as :fds travel with the error unindexed.

reply_message(call, message, opts \\ [])

@spec reply_message(Rebus.Service.Call.t(), Rebus.Message.t(), keyword()) ::
  :ok | {:error, reply_error() | Rebus.Message.encoding_error()}

Answers a call with a reply built by hand.

message must be a :method_return or an :error; any other type returns {:error, {:invalid_message_type, type}}. A method return must have the declared out-signature, the call's :out_signature, or this returns {:error, :signature_mismatch}. An error can carry any body. The message's reply_serial and destination are replaced with the call's own.

The message is encoded in the calling process first, so one that cannot be encoded returns the error Rebus.Message.encode/2 would report and leaves the call open. Otherwise this takes the options and returns the values of reply/3, except that there is no :fds option: the message carries its own descriptors in unix_fds.

take_fds(call, timeout \\ 5000)

@spec take_fds(Rebus.Service.Call.t(), non_neg_integer()) ::
  {:ok, [Rebus.UnixFD.t()]} | {:error, take_fds_error()}

Takes ownership of the handles that arrived with a call.

Until a call completes, its handles are borrowed from the connection, which closes them when the call is answered, expires or fails. This makes the calling process their owner instead, so they outlive the call and close when this process closes them or exits. The call stays pending, and still has to be answered.

Return values

  • {:ok, handles} - the handles, the same ones as the call's :unix_fds and in the same order, now owned by the calling process. A call that carried none returns {:ok, []} without reaching the connection.
  • {:error, :unknown_call} - the handles were already taken, or the call was answered, expired, or its export is gone.
  • {:error, :timeout} - the connection did not answer within timeout. It may still make this process their owner afterwards, or it may not: either way each handle closes exactly once, when this process or the connection is done with it. Rebus.UnixFD.owner/1 on the call's :unix_fds tells which once the connection has caught up, and a second take_fds/2 answers {:error, :unknown_call} if the first one went through.
  • {:error, :disconnected} - the connection stopped, closing the handles it still owned. Only if it was killed as it handed them over can this process have become their owner, which Rebus.UnixFD.owner/1 on the call's :unix_fds tells.

unexport(conn, ref, timeout \\ 5000)

@spec unexport(pid(), reference(), non_neg_integer()) ::
  :ok
  | {:error,
     :not_connected | :disconnected | :timeout | :remote_connection_unsupported}

Removes an export.

Each call on the export still waiting for its answer is failed with org.freedesktop.DBus.Error.Failed, and answering it afterwards returns {:error, :unknown_call}. Removing an export that is already gone returns :ok.