Madness.Responder behaviour (Madness v0.5.0)
View SourceThe behaviour a responder backend implements.
Madness publishes by delegating to whatever responder the host already runs,
and only acts as a responder itself when there is none. Each such delegate is
a Madness.Responder backend. Application code does not call these callbacks;
it calls Madness.publish/2, which selects a backend and drives it.
The callbacks
connect/0, disconnect/1, and optional handle_info/2 maintain a
presence connection used to detect daemon availability. Publications use
separate registration connections. Failed presence connections are retried
with backoff.
capabilities/0 declares what the backend can honour. See "Capabilities".
publish/3 registers a service and returns an opaque handle plus the name
the responder actually accepted. unpublish/1 withdraws it.
Its options carry the deadline from Madness.publish/2; a backend must stop
waiting by that deadline.
Process ownership is the revocation mechanism
publish/3 is called from the process that will own the registration,
and that process exists for exactly as long as the registration should. Any
socket or connection the callback opens therefore belongs to that process, and
dies with it.
Daemon-backed implementations must open the registration connection in this process and must not pool it. The daemon can then withdraw only this service when the owning process exits.
unpublish/1 is the orderly path, called from the same process during
termination. It should be idempotent: it can race with the owner exiting.
It must also return promptly because shutdown has a bounded window.
Empty TXT records
A service with no metadata arrives as txt: []. DNS-SD requires the TXT
record to exist and forbids one containing zero strings, so a backend must
publish a TXT record holding a single empty string — not no record at
all. Both delegated daemons do this for you when handed an empty TXT.
Capabilities
A backend declares the scopes it supports. Madness.publish/2 refuses a
scope the selected backend cannot honour:
:interface_scope- can restrict a publication to one interface.:family_scope- can restrict a publication to IPv4 or IPv6 alone.
Apple's IPC protocol has no per-address-family control, so its backend
declares :interface_scope only, and family: :inet is rejected there rather
than quietly ignored. family: :any is the portable choice.
Asynchronous events
A responder can rename a service long after it was registered, when a new
conflicting host appears. A backend that learns of such an event sends a
message to its owning process — the one that called publish/3:
send(owner, {:madness_responder, handle, {:renamed, "My Printer (2)"}})
send(owner, {:madness_responder, handle, {:error, reason}})Madness forwards these to the caller of Madness.publish/2 when it asked for
notify: true. {:error, {:connection_lost, reason}} has lifecycle meaning:
when it names a handle the publication still holds, Madness terminates that
publication with reason {:shutdown, {:connection_lost, reason}}. Monitor
the returned publication pid to observe this regardless of notification
settings, then publish again if the service should return. Other backend
errors are notifications and do not end the publication. A backend with
nothing asynchronous to report never sends any.
Interface-scoped publications have the same terminal-handle rule. Losing the
concrete interface that existed at registration time exits the owning process
with {:shutdown, {:interface_lost, interface}}; losing the interface event
stream exits it with {:shutdown, :interface_events_unavailable}. Madness
does not move an existing backend handle to an interface that arrives later.
Example
defmodule MyBackend do
@behaviour Madness.Responder
@impl true
def connect do
# ... reach the daemon, and keep the connection so its closure is
# noticed. Publications never use this one.
{:ok, connection}
end
@impl true
def disconnect(connection), do: close(connection)
@impl true
def handle_info({:tcp_closed, socket}, %{socket: socket}), do: :disconnected
def handle_info(_message, connection), do: {:ok, connection}
@impl true
def capabilities, do: [:interface_scope]
@impl true
def publish(service, scope, opts) do
# ... open a connection *here*, in this process, register on it, and
# read back the accepted name, waiting no later than
# Keyword.get(opts, :deadline, :infinity)
{:ok, connection, accepted_name}
end
@impl true
def unpublish(connection), do: :ok = close(connection)
endMadness.Responder.MDNSResponder is a complete worked example.
Summary
Types
A scope a backend may be asked to enforce
A backend's presence connection, owned by Madness's connection process.
An opaque, backend-defined value identifying one registration.
An option threaded into one registration attempt.
The scope for one registration.
Callbacks
The scopes this backend can enforce. See "Capabilities".
Opens this backend's connection to the host's daemon.
Closes the presence connection.
Interprets a message that arrived for the presence connection.
Registers service within scope.
Withdraws a registration, sending goodbye packets.
Types
@type capability() :: :interface_scope | :family_scope
A scope a backend may be asked to enforce
@type connection() :: term()
A backend's presence connection, owned by Madness's connection process.
Opaque to madness, and entirely separate from the per-publication connections
that publish/3 opens.
@type handle() :: term()
An opaque, backend-defined value identifying one registration.
Madness stores it and hands it back to unpublish/1; nothing outside the
backend interprets it.
@type publish_opt() :: {:deadline, integer() | :infinity}
An option threaded into one registration attempt.
:deadline is an absolute System.monotonic_time(:millisecond) value, or
:infinity. See publish/3.
@type scope() :: %{interface: pos_integer() | nil, family: :any | :inet | :inet6}
The scope for one registration.
:interface- an OS interface index, ornilfor all interfaces. Names are resolved to an index by Madness immediately before each call, so a backend never has to resolve one itself and never sees a stale index.Index
0is never passed. Both delegated daemons read it as "every interface", so a lookup that failed and returned0would widen a deliberately scoped publication rather than fail it;nilis how "every interface" is asked for, and a backend is entitled to treat a0as the bug it would be.:family-:any,:inetor:inet6. Only ever narrower than:anyfor a backend that declared:family_scope.
Callbacks
@callback capabilities() :: [capability()]
The scopes this backend can enforce. See "Capabilities".
@callback connect() :: {:ok, connection()} | {:error, term()}
Opens this backend's connection to the host's daemon.
Called from Madness's connection process, which owns the returned connection and retries this on failure. The connection is a presence one: it exists so that losing the daemon is noticed. Publications never use it — each opens its own, in its own process.
Whatever is returned is opaque to madness and is handed back to
disconnect/1 and handle_info/2.
@callback disconnect(connection()) :: :ok
Closes the presence connection.
@callback handle_info(message :: term(), connection()) :: {:ok, connection()} | :disconnected
Interprets a message that arrived for the presence connection.
Madness's connection process owns the connection, so the daemon going away
arrives as a message in its mailbox — in a shape only this backend knows.
Anything it does not recognise is passed here, and answering :disconnected
makes it withdraw availability and start reconnecting.
Optional. A backend that does not implement it will not notice the daemon disappearing: availability stays true until something else disturbs the connection process, and publications fail individually instead. Implement it.
@callback publish(Madness.Service.t(), scope(), opts :: [publish_opt()]) :: {:ok, handle(), accepted_name :: String.t()} | {:error, term()}
Registers service within scope.
Called from the process that will own the registration. Returns an opaque
handle and the instance name the responder accepted, which may differ from
service.name if it had to resolve a conflict.
opts carries :deadline: the absolute time, on the
System.monotonic_time(:millisecond) clock, at which Madness.publish/2
stops waiting for this registration, or :infinity when nothing is waiting on
a clock. A missing key means :infinity. A backend must not wait past it, and
must ignore any key it does not recognise — options are added here over time,
and understanding one is never a condition of being a backend.
A deadline only ever shortens a backend's own ceiling; it never extends it. A
backend bounds a wedged daemon for the sake of the timeout: :infinity caller
who has no clock of their own, so its budget stays a hard cap and a later
deadline does not lift it.
@callback unpublish(handle()) :: :ok
Withdraws a registration, sending goodbye packets.
Called from the owning process. Must be idempotent.