Automating Bonjour Service Discovery Tests on a Cloud Mac

Automating Bonjour Service Discovery Tests on a Cloud Mac

When working remotely, the fact that an application can connect through an explicit hostname and port does not mean Bonjour service discovery is functioning correctly. Printing, screen casting, debugging proxies, and LAN collaboration tools often depend on mDNS. If the service type, TXT record, or network interface changes, the only visible symptom may be that “nearby devices” disappear. These issues are well suited to protocol-level regression testing on a cloud Mac: register a temporary service, verify browsing and resolution, and confirm proper cleanup on exit instead of waiting for intermittent failures on physical devices.

Define the Test Boundary First

Bonjour typically sends link-local mDNS multicast traffic over UDP 5353. A reachable TCP port does not make that traffic pass automatically through routers, ordinary SSH tunnels, or remote desktop connections. Cloud-based testing should therefore be divided into two layers:

  1. On a single macOS node, verify registration, browsing, SRV resolution, and TXT records.
  2. On the target LAN, separately test communication with physical devices to validate switches, wireless networks, and multicast policies.

The first layer belongs in the CI pipeline for every commit. It can detect misspelled service types, incorrect ports, missing metadata, and processes that fail to exit. The second layer is network acceptance testing and cannot be replaced by results from the first.

A passing Bonjour test only proves that the service discovery contract holds within the current test boundary. It does not prove that multicast crosses a remote network boundary.

Assign a dedicated service type to the product, such as _myapp-test._tcp. Do not reuse _http._tcp, because other services on the test node could contaminate the results. The instance name must also be unique and should include an identifier for the current job.

Build a Minimal Test Loop with dns-sd

macOS includes dns-sd, so no additional dependencies are required. The following script starts a temporary HTTP service, begins browsing, registers a Bonjour instance, resolves that instance, and verifies its TXT data. It can be stored in the repository as ci/test-bonjour.sh.

#!/bin/bash
set -euo pipefail

RUN_ID="${CI_RUN_ID:-$(date +%s)}"
INSTANCE="vmcache-${RUN_ID}"
TYPE="_myapp-test._tcp"
PORT="${BONJOUR_TEST_PORT:-8088}"
WORK_DIR="$(mktemp -d)"
BROWSE_LOG="$WORK_DIR/browse.log"
RESOLVE_LOG="$WORK_DIR/resolve.log"

HTTP_PID=""
BROWSE_PID=""
REGISTER_PID=""
RESOLVE_PID=""

cleanup() {
  for pid in "$RESOLVE_PID" "$REGISTER_PID" "$BROWSE_PID" "$HTTP_PID"; do
    if [[ -n "$pid" ]]; then
      kill "$pid" 2>/dev/null || true
      wait "$pid" 2>/dev/null || true
    fi
  done
  rm -rf "$WORK_DIR"
}
trap cleanup EXIT INT TERM

python3 -m http.server "$PORT" --bind 0.0.0.0 \
  >"$WORK_DIR/http.log" 2>&1 &
HTTP_PID=$!

dns-sd -B "$TYPE" local. >"$BROWSE_LOG" 2>&1 &
BROWSE_PID=$!

dns-sd -R "$INSTANCE" "$TYPE" local. "$PORT" \
  "path=/" "run=$RUN_ID" >"$WORK_DIR/register.log" 2>&1 &
REGISTER_PID=$!

sleep 4
grep -F "$INSTANCE" "$BROWSE_LOG"

dns-sd -L "$INSTANCE" "$TYPE" local. >"$RESOLVE_LOG" 2>&1 &
RESOLVE_PID=$!
sleep 3
kill "$RESOLVE_PID" 2>/dev/null || true
wait "$RESOLVE_PID" 2>/dev/null || true
RESOLVE_PID=""

grep -E "path=/|run=$RUN_ID" "$RESOLVE_LOG"
grep -E "$PORT" "$RESOLVE_LOG"
curl --fail --silent --show-error "http://127.0.0.1:$PORT/" >/dev/null

Browsing must start before registration, or a short-running job may miss the service-added event. The script does not run dns-sd in the foreground because its browse and resolve commands continue waiting indefinitely. CI must define an observation window and terminate those processes explicitly.

Align Assertions with the Service Contract

Checking only that the instance name appears is not enough. A reliable regression test should verify at least four properties: the service type, instance name, port, and TXT records. TXT records should contain only the low-sensitivity metadata actually required during discovery, such as a protocol version, capability flags, and a health-check path. They should never contain tokens, passwords, or internal credentials.

A practical convention is to publish the protocol version as api=2 and capabilities as features=sync,preview. Assert each field individually instead of comparing an entire output line. The output from dns-sd includes timestamps and interface information, so full-line snapshots can change between system versions.

If the application rejects older protocols, register another instance with api=1 and verify that the client ignores it. This tests the selection logic rather than merely proving that the client can “see a name.” After successful resolution, send one minimal request to the actual port as well. Otherwise, a service advertising 8088 while listening on 8089 could produce a false pass.

Isolate Concurrent Jobs, Residual Processes, and Exposure

When multiple jobs run concurrently on the same node, both instance names and ports must be isolated. Adding a job ID to the instance name prevents discovery results from being confused, but it does not stop two HTTP services from competing for the same port. The CI scheduler should allocate ports in advance or check for an existing listener before startup with lsof -nP -iTCP:$PORT -sTCP:LISTEN.

Every background process must be covered by the trap. If the registration process remains alive after an assertion fails, the next test may discover the stale instance and produce a difficult-to-reproduce false positive. During cleanup, stop resolution and registration first, followed by browsing and the test service.

The example listens on all interfaces to complete an end-to-end check. Even on a dedicated cloud Mac, follow the principle of least exposure: open the test port only on a controlled network and only for a short period. If no network request is needed, omit the HTTP service and verify registration and resolution only. Run lsof before and after the test to confirm that no listener remains on the port.

Diagnose Failures from the Output

If browsing finds nothing, first confirm that the service type matches exactly, including the leading underscore and the _tcp suffix. Then check whether the registration process exited prematurely. If browsing succeeds but resolution fails, inspect instance-name escaping, the domain argument, and local hostname resolution. If resolution succeeds but the port is unreachable, check the listening address, port conflicts, firewall policy, and service startup order.

CI should retain browse.log, resolve.log, and the registration process output, but sanitize them before uploading. On failure, also capture scutil --get LocalHostName, networksetup -listallhardwareports, and the lsof output for the relevant port. Together, these records are usually enough to distinguish naming, interface, and listener problems.

When running these tests on VMCache dedicated physical nodes, do not assume that the remote connection path forwards mDNS. Keep protocol-level regression tests inside the node, and perform physical-device discovery separately as part of acceptance testing on the target LAN. Record the two sets of results independently so that failure boundaries remain clear and reproducible.

Frequently asked questions

Does an SSH tunnel automatically carry Bonjour discovery traffic?

No. Bonjour normally uses link-local mDNS multicast on UDP 5353. Standard SSH port forwarding carries selected connections, not multicast discovery traffic between network segments.

How should parallel Bonjour tests avoid collisions?

Give every job a unique instance name and a separate port. Install an exit trap that terminates the registration process, browser process, resolver, and test service even when an assertion fails.

Dedicated physical nodes

Choose a cloud Mac for builds, testing, and MLX inference

Choose your M4 model, memory, storage, node, and rental term for your workload. Every rental includes a dedicated physical machine, not a virtual machine.

Choose a rental plan