Madness.Responder behaviour (Madness v0.5.0)

View Source

The 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)
end

Madness.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

capability()

@type capability() :: :interface_scope | :family_scope

A scope a backend may be asked to enforce

connection()

@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.

handle()

@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.

publish_opt()

@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.

scope()

@type scope() :: %{interface: pos_integer() | nil, family: :any | :inet | :inet6}

The scope for one registration.

  • :interface - an OS interface index, or nil for 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 0 is never passed. Both delegated daemons read it as "every interface", so a lookup that failed and returned 0 would widen a deliberately scoped publication rather than fail it; nil is how "every interface" is asked for, and a backend is entitled to treat a 0 as the bug it would be.

  • :family - :any, :inet or :inet6. Only ever narrower than :any for a backend that declared :family_scope.

Callbacks

capabilities()

@callback capabilities() :: [capability()]

The scopes this backend can enforce. See "Capabilities".

connect()

@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.

disconnect(connection)

@callback disconnect(connection()) :: :ok

Closes the presence connection.

handle_info(message, connection)

(optional)
@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.

publish(t, scope, opts)

@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.

unpublish(handle)

@callback unpublish(handle()) :: :ok

Withdraws a registration, sending goodbye packets.

Called from the owning process. Must be idempotent.