Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

Discovery (mDNS / Bonjour)

Frameworks
.NET MAUI
Operating Systems
Android
iOS
macOS
Windows
Linux
Terminal window
dotnet add package Shiny.Net.HttpServer.Discovery

Binding a port solves being reachable. It does not solve being found.

The address is assigned by whatever network the device joined, it changes when the device moves, and the ways out of that are: a QR code, someone typing an IP address, or this. mDNS is what every printer, speaker and NAS on the link already uses. It needs no server, no account and no internet.

builder.Services.AddShinyHttpServer(
http =>
{
http.Options.Address = IPAddress.Any;
http.AddHttpServerAdvertisement(o =>
{
o.ServiceType = "_myapp._tcp";
o.TxtRecords["role"] = "controller";
});
},
autoStart: false
);

The advertisement’s hosted service is registered after the server’s own, which is the order they start in, so the first announcement carries a port that is already bound. (It recovers either way — the advertiser watches the server’s state — but there is no reason to make it.)

The advertisement follows the server:

  • published when it starts listening, on the port it actually bound (including an OS-assigned one),
  • withdrawn with a goodbye packet when it stops, so peers drop it immediately rather than waiting out the TTL,
  • re-published on the new port if the server is restarted onto one,
  • re-announced when the device changes network, which is when a stale advertisement is worst,
  • held through a stop that is only one half of a restart or a rebind — see below.

A restart is a Stopped and a Running milliseconds apart, and a rebind after the phone moved networks is the same. Withdrawing on that Stopped is wrong twice over: peers get a goodbye and then a fresh announcement for a service that never actually went away, and anything holding a resolved address drops it and has to find the device again over a gap that was never real.

The advertiser subscribes to server.StateTransitioned rather than StateChanged, so it can tell a stop from a pause. A Stopped carrying Restarting or NetworkChanged leaves the record standing; the Running that follows either finds the same port and does nothing at all, or finds a new one and moves the record onto it. If the start half never lands, the core reports Stopped a second time with BindFailed, and that one is a real stop and does withdraw. ListenerFaulted withdraws too — recovering from it is optional and it is not instant, and a listener that died underneath the server is exactly the outage the peers watching it should be told about.

When the responder will not take the registration

Section titled “When the responder will not take the registration”

The moment the advertiser publishes is the moment a responder is least likely to answer: the server has just bound after a network change, and the platform’s mDNS stack is coming back at its own pace. One refused registration used to be the end of it, and the failure it left behind is the hard kind — a server running perfectly, answering everything, findable by nobody, and looking completely healthy from inside the app.

Publishing is retried on a bounded backoff:

Property Default
PublishAttempts 3 1 restores the old single-attempt behaviour
PublishRetryDelay 1 second Doubles per attempt
MaxPublishRetryDelay 15 seconds Ceiling for the doubling

When the attempts are spent it is logged at Error with the exception — not Warning, because a crash reporter’s Microsoft.Extensions.Logging bridge files an event for the first and only a breadcrumb for the second. A server that says it is running but reports no listen URL is logged too, rather than silently publishing nothing; that is expected only for a server served entirely through a tunnel, which has no local port to advertise.

State changes are also applied in the order the server actually moved in. The mDNS work has to come off the server’s lifecycle thread — it talks to a platform responder and it is slow — and once it is off, two transitions milliseconds apart are free to land in either order. A withdrawal applied on top of the publication that followed it would leave the server running and unfindable, with nothing coming to correct it.

Without a container:

await server.StartAsync();
await using var advertisement = await server.AdvertiseAsync(mdns, o => o.ServiceType = "_myapp._tcp");
Property Default Notes
ServiceType _http._tcp See below
InstanceName the machine name What the user already calls this device
Path / Published as the conventional path TXT record
TxtRecords path, scheme Whatever a peer needs to decide it cares
PublishAttempts 3 See above
PublishRetryDelay 1 second Doubles per attempt
MaxPublishRetryDelay 15 seconds Ceiling for the doubling

Read the final name back from IHttpServerAdvertiser.Publication. When the link already has a service with that name the responder renames it — typically by appending (2) — and the publication carries what it settled on.

builder.Services.AddHttpServerLocator();
// one shot — "connect to my other device"
var found = await locator.FindFirstAsync("_myapp._tcp", TimeSpan.FromSeconds(5));
using var client = new HttpClient { BaseAddress = found!.BaseAddress };
// everything that answers inside the window
var all = await locator.FindAllAsync("_myapp._tcp", TimeSpan.FromSeconds(3));
// or bind a list to it: never completes on its own
await foreach (var change in locator.WatchAsync("_myapp._tcp", ct))
{
if (change.Found) devices.Add(change.Server);
else devices.RemoveAll(x => x.InstanceName == change.Server.InstanceName);
}

A DiscoveredHttpServer is already a URL a client can call: the base address is built from the service’s scheme and path TXT records and its first IPv4 address, since that is still what a person types and what a QR code carries. BaseAddressFor(address) builds the same URL through any of its other addresses.

Filtering on a TXT record is how an app picks its own instance out of a crowded link:

var controller = await locator.FindFirstAsync(
"_myapp._tcp",
TimeSpan.FromSeconds(5),
x => x.TxtRecords.TryGetValue("role", out var role) && role == "controller"
);

An instance that has not resolved to an address and port yet is skipped rather than handed over with no URL. Finding nothing is an answer — null, or an empty list — not a failure.

Publishing and browsing go through NSNetService on Apple platforms and NsdManager on Android, so no multicast entitlement is required. What is required:

Platform Needs
iOS / Mac Catalyst / macOS NSLocalNetworkUsageDescription and every browsed service type in NSBonjourServices
Android INTERNET and ACCESS_NETWORK_STATE
Windows / Linux / server .NET A managed responder binds UDP 5353 — the firewall has to allow it

Browsing for a type that is not declared in NSBonjourServices returns nothing, with no error. See Mobile for the rest of the manifest entries and a runtime check for them.