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

Hosting & Lifecycle

There are three ways to get a server, and which one you want depends on who owns the container.

var server = new HttpServer(new HttpServerOptions { Port = 8080 });
server.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
await server.RunAsync();

Everything works except ctx.RequestServices, which resolves nothing. This is the right shape for a console tool, a test fixture, or anything small enough that a container would be ceremony.

RunAsync starts the server and waits until its cancellation token fires, then shuts down gracefully. It is the one-liner for a console host; StartAsync/StopAsync are what an app with a UI uses.

var builder = HttpServer.CreateBuilder();
builder.Configure(o =>
{
o.Port = 8080;
o.HideExceptionDetails = false; // development
});
// The server's own features hang off the builder…
builder.AddAuthentication().AddJwtBearer(o => o.SigningKey = key);
builder.AddRateLimiter(o => o.GlobalPolicy = new FixedWindowRateLimitPolicy(100, TimeSpan.FromMinutes(1)));
builder.AddHealthChecks().AddServerCheck();
// …and everything else goes on the container underneath it.
builder.Services.AddLogging(l => l.AddSimpleConsole());
builder.Services.AddSingleton<IWidgetStore, InMemoryWidgetStore>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
var app = builder.Build();
app.MapMyAppEndpoints();
await app.RunAsync();

builder.Options is the same object Configure hands you, so either spelling works. Build() can only be called once — it builds the provider and hands the server back.

ShinyHttpServerBuilder is where every registration in this library lives: AddAuthentication, AddCors, AddRateLimiter, AddHealthChecks, AddOutputCache, AddSessions, the tunnels, the mDNS advertisement, the mobile lifecycle — all of them extend the builder rather than IServiceCollection. Typing builder. lists what this server can do; typing services. lists what every library in the app has ever registered, and an embedded server’s features are exactly the ones nobody knows to go looking for.

builder.Services is still right there for your own registrations, and for anything the builder does not cover.

Logging is optional. An app that never called AddLogging gets a no-op factory rather than a resolution failure at startup.

Inside an app that already has a container

Section titled “Inside an app that already has a container”

A MAUI app, a generic host, a Shiny host — anything with an existing IServiceCollection:

builder.Services.AddShinyHttpServer(http =>
{
http.Options.Port = 8080;
http.AddAuthentication().AddBasic(o => o.AddUser("ada", pw));
http.AddHealthChecks().AddServerCheck();
http.Configure(server =>
{
server.UseAuthentication();
server.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
server.MapMyAppEndpoints();
});
});

Same builder, same calls as the standalone form above — the only difference is where the container comes from. That is the point of it: there is one way to configure this server, not one for a console app and another for MAUI.

The callback runs immediately; it is registration. The Configure(server => …) callbacks inside it run once, when the server is first resolved, so they can take anything out of the container while registering routes.

By default the server is registered as a singleton and started with the host through an IHostedService. Pass autoStart: false when the app should decide:

builder.Services.AddShinyHttpServer(http => http.Options.Port = 8080, autoStart: false);

The server is still registered and fully configured, just not listening — which is exactly what an app with a “share over Wi-Fi” toggle wants. Resolve it and call StartAsync when the user says so.

A fresh IServiceScope is created before the pipeline runs and disposed after it completes, including IAsyncDisposable registrations. Scoped services therefore behave exactly as they do in ASP.NET Core: one instance per request/response exchange, shared by every middleware, endpoint class and handler involved in it.

app.MapGet("/scope", ctx =>
{
var a = ctx.GetRequiredService<RequestId>();
var b = ctx.GetRequiredService<RequestId>();
// same-instance=True — and a different instance on the next request
return ctx.Response.WriteAsync($"same-instance={ReferenceEquals(a, b)}");
});

ctx.GetRequiredService<T>() and ctx.GetService<T>() are shorthand for ctx.RequestServices. Generated endpoint classes get constructor injection from the same scope, and middleware registered as a type (app.Use<TMiddleware>()) is resolved per request from it too.

Start and stop are ordinary runtime operations here, not just process startup and shutdown. An app with a toggle flips this switch repeatedly over one process lifetime, so the transitions are serialized against each other, idempotent, and leave the server genuinely restartable.

await app.StartAsync(); // binds and begins accepting; returns once listening
await app.StopAsync(); // unbinds, then waits for in-flight requests
await app.RestartAsync(); // both, as one operation, re-reading Options
Member What it gives you
State Stopped, Starting, Running or Stopping
StateChanged Raised on every transition, on the thread that caused it
StateTransitioned The same transitions, each carrying why and the exception behind it
LastStateChange The transition that produced the current State, for whoever was not subscribed at the time
IsRunning State == Running
ListenUrl The URL being served — the real port when Port was 0
ListenUrls Every URL, when several endpoints are configured
ActiveConnections Connections currently being served, tunnelled ones included — one keep-alive connection counts once however many requests it carries
NetworkAddressesChanged Raised when the machine’s addresses change while running. See configuration

Starting an already-running server does nothing rather than throwing, because the caller is often a button and a double tap is not a bug. A failed bind returns to Stopped rather than sticking in Starting.

app.StateChanged += (_, state) => this.Status = state.ToString();
// Port 0 lets the OS choose; read it back once running.
await app.StartAsync();
Console.WriteLine($"Serving on {app.ListenUrl}");

A handler that throws is caught and logged, and every other subscriber is still told. A UI binding raising on the wrong thread is not allowed to be the reason a server that started successfully goes back down.

State answers “is it up”, which is enough to draw a toggle and nothing else. The question an app actually gets asked — it stopped, why? — is StateTransitioned:

app.StateTransitioned += (_, change) =>
{
if (change is { State: HttpServerState.Stopped, Reason: not HttpServerStateReason.Requested })
logger.LogError(change.Exception, "The server went down: {Reason}", change.Reason);
};
HttpServerStateReason What happened
Requested The app called StartAsync / StopAsync. The only reason that is never a fault
Restarting Part of a RestartAsync — present on the stop half too, so a handler knows a start is coming
NetworkChanged A rebind driven by the machine’s addresses changing
BindFailed The bind was refused and the retries are spent. Exception carries the failure
ListenerFaulted The listener stopped accepting while the server believed it was running. Exception carries the cause
Disposed The server was disposed. Nothing follows it

Restarting is the one worth acting on even when nothing is wrong: a subscriber that tears down a notification, an mDNS advertisement or a “reachable at” line on every Stopped will rebuild it a moment later for every rebind. Reason == Restarting means down for a moment, not down.

LastStateChange holds the same record for code that arrived late — a crash reporter assembling context, a diagnostics screen the user opens after the fact, a background task that woke up to find the server down.

A listening socket can go away without the server being told: the OS reclaims it, an interface is torn down mid-accept, a platform suspends the process and takes the descriptor with it. Left alone, that produces the worst possible failure — a server that goes on reporting Running with nothing behind it, refusing every connection, which the user cannot fix by toggling it off and on because it already thinks it is on.

The accept loop no longer ends quietly for any reason other than a stop:

  • A transient accept failure is retried with a bounded backoff. The counter resets on every connection accepted, so a flaky minute costs nothing.
  • Failures that keep coming, or a listener that stops accepting outright, are a fault: logged at error level, reported as ListenerFaulted with the cause attached, and — by default — rebound.

Both are tuned by resilience options. Set RecoverFromListenerFaults = false to have the fault stop the server instead; it is still reported either way. What that setting chooses is whether the server tries to come back, not whether anyone finds out.

RestartAsync re-reads Options, so a changed port or TLS configured after the fact takes effect. Routes and middleware are not re-read — the middleware pipeline is composed once and stays composed. Routes, however, can be changed at any time without a restart; see Routing.

The start half of a restart retries. A restart is the one operation where failing leaves the server worse off than not having tried: it was running a moment ago, and a bind refused because the network is half up or the old port is still in TIME_WAIT would otherwise leave it stopped for good. If the retries are spent, RestartAsync still throws and the transition to Stopped carries BindFailed with the exception. A start the app asked for — plain StartAsync — is never retried: the caller gets the failure immediately and decides, which is louder than a button that looks stuck.

StopAsync unbinds the listener first so nothing new arrives, then waits for in-flight requests to finish. Connections still running when the cancellation token fires are aborted.

DisposeAsync stops the server and releases everything. In a console host the usual shape is:

using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
await app.RunAsync(cts.Token);

ServeAsync(IConnection) runs the whole pipeline over a connection the server did not accept itself. That is how tunnelling works — a tunnel provider dials out, unpacks inbound streams, and hands each one to the server — and it is available to anything else that can produce an IConnection, including in-memory pipes for tests.

A server that is Stopped still serves tunnelled connections: not listening is not the same as not running.