Skip to content
This repository was archived by the owner on Jul 20, 2026. It is now read-only.

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Kalicz.ParallelAspire

build NuGet

Parallel-safe local ports for .NET Aspire AppHosts.

Running more than one AppHost at once — say, one per git worktree — fails because they all grab the same fixed dashboard / OTLP / resource-service ports. Kalicz.ParallelAspire probes for a free set of ports for each instance, points Aspire at them via the standard env vars, and serializes startup with a cross-process lock so siblings never pick the same ports.

Install

dotnet add package Kalicz.ParallelAspire

Usage

Reserve at the very top of your AppHost, hold the lock across startup, and release it once the app has started:

using ParallelAspire;

DistributedApplication app;
using (var ports = await PortReservation.ReserveAsync())
{
    var builder = DistributedApplication.CreateBuilder(args);
    // ... wire up your resources ...
    app = builder.Build();
    await app.StartAsync();   // dashboard + OTLP ports are bound by the time this returns
}                             // lock released here, after startup
await app.WaitForShutdownAsync();

That's it — every instance now gets its own dashboard and OTLP ports.

Order matters — reserve before CreateBuilder. The ports are handed to Aspire purely through environment variables (ASPNETCORE_URLS for the dashboard, plus the OTLP and resource-service URLs), and Aspire reads those when the builder is created. ReserveAsync sets them, so it must run before DistributedApplication.CreateBuilder(args) — that's why it's the first line inside the using. Call it after the builder and the dashboard/OTLP ports won't take effect (you'll only get the extra ports, which you read back yourself).

Why the split instead of app.Run()? The lock has to outlive port-binding but not the whole session. StartAsync() returns once the host has bound its ports; releasing the lock then lets a waiting sibling proceed, while WaitForShutdownAsync() keeps the app running unlocked. A sibling AppHost blocks on the lock until you exit the using, so it always sees your ports as taken and steps to its own. If a sibling is mid-startup, ReserveAsync waits and logs a heartbeat every 5 seconds so you can tell it's alive, not hung.

Extra ports

Need pinned host ports for your own resources (Redis, RabbitMQ, …)? Pass the count — there's an overload that takes just the number of extra ports — and read them back in order:

using (var ports = await PortReservation.ReserveAsync(2))   // 2 extra ports
{
    var builder = DistributedApplication.CreateBuilder(args);
    builder.AddRedis("redis", port: ports.ExtraPorts[0]);
    builder.AddRabbitMQ("rabbit", port: ports.ExtraPorts[1]);

    // Aspire logs the dashboard URL itself on startup, but not your extra ports — log those if you want quick links from the CLI:
    Console.WriteLine($"redis → {ports.ExtraPorts[0]}, rabbit → {ports.ExtraPorts[1]}");

    app = builder.Build();
    await app.StartAsync();
}   // (app declared before the using, as in the Usage example above)

For anything beyond the count (a custom LockName, different bases, pinned mode) use the ReserveAsync(o => …) overload instead — see Options.

One port, one resource

ExtraPorts is a plain list — reading an element has no side effect, so each port is yours to assign exactly once. If you pin the same port to two resources:

builder.AddRedis("redis",     port: ports.ExtraPorts[0]);
builder.AddRabbitMQ("rabbit", port: ports.ExtraPorts[0]);   // same port — don't

…the first binds and the second container fails to start with a Docker bind error (port is already allocated), shown as failed in the dashboard. Aspire does not silently reassign — pinning a host port means exactly that port. (If you wanted auto-assignment, omit the port: argument and don't use ExtraPorts at all.) Reading past what you reserved — ports.ExtraPorts[2] when ExtraPortCount was 2 — throws ArgumentOutOfRangeException.

Options

All optional:

Option Default Purpose
LockName calling assembly name Coordinates only with your AppHost's siblings (a lock file under temp), not unrelated apps.
DashboardBase 16000 First port probed for the dashboard frontend.
OtlpBase 19000 First port of the OTLP block (gRPC, HTTP, resource-service, then extras).
ExtraPortCount 0 Extra ports to reserve beyond Aspire's own.
OffsetEnvironmentVariable null Env var holding an integer offset; when set, ports are pinned to base + offset deterministically — no lock, no probing.
HeartbeatInterval 5s How often the "still waiting for the lock" message is written to the console while blocked on a sibling.
var ports = await PortReservation.ReserveAsync(o =>
{
    o.LockName = "MyApp-Aspire-Ports";
    o.DashboardBase = 16036;
    o.OtlpBase = 19600;
    o.ExtraPortCount = 1;
    o.OffsetEnvironmentVariable = "MYAPP_PORT_OFFSET";
});

Set MYAPP_PORT_OFFSET=10 to pin every port to its base + 10 — deterministic and lock-free, handy for stable, reproducible ports in scripts or CI.

License

MIT

About

Parallel-safe local ports for .NET Aspire AppHosts: run multiple AppHosts (e.g. git worktrees) side by side without dashboard/OTLP port collisions.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages