Retiring Named Pipes: A Simpler Fix for Dev Port Conflicts

4 min read

Back in February I wrote about solving dev-server port conflicts with HTTP over named pipes. The short version: I run a lot of parallel Claude Code sessions, each in its own git worktree, and each one needs to spin up a demo site. Fixed ports don’t work when you’ve got several instances running at once, and dynamic ports create a discovery problem — how does a script or an AI agent find out which port a given worktree’s site landed on?

Named pipes were a genuinely fun rabbit hole, and they worked. But a few months of actually living with that solution taught me it had more moving parts than the problem deserved.

What started to bug me

Three things, mainly.

First, named pipes and Unix sockets are two different transports depending on whether you’re on Windows or macOS/Linux, so the Kestrel configuration had to branch on platform. Not the end of the world, but it’s exactly the kind of code that only gets exercised on someone else’s machine.

Second, the “which worktree am I in” logic — walk up from .git, check if the path contains worktrees, fall back to the branch name — had to be duplicated in C# for the server and JavaScript for the client generator. Every time I touched one, I had to remember to touch the other.

Third, and the thing that actually pushed me to fix it: I wanted the same trick in Umbraco.Automate, a completely separate repo with the exact same problem. Copy-pasting a named pipe listener, a custom /site-address discovery endpoint, and two copies of identifier-detection logic into a second codebase was the point where “clever solution” started to feel like “thing I now maintain in two places.”

The simpler idea: let git remember the port

The thing I’d been missing is that git already solves exactly the state-storage problem I was solving badly with pipes and sockets: per-worktree config. Since Git 2.20, git config --worktree lets you set a config value scoped to this specific worktree — not the repo, not the branch, the physical checkout on disk. It lives in .git/config.worktree (or .git/worktrees/<name>/config.worktree for a linked worktree), it’s never committed, and — this is the part I like most — it’s deleted automatically when you run git worktree remove. No cleanup step to forget.

So instead of exposing a whole separate HTTP transport just so tools could avoid knowing the port, I flipped the problem around: what if the port itself was just… normal, and the only thing worth solving was remembering it?

The demo site picks a free port once, on first run in a given worktree, and writes it to git config --worktree wdp.port <port>. Every later run in that worktree reads the same value back. Any other tool — a client generator, a teammate, an AI agent — gets the same answer with one command:

git config --worktree --get wdp.port

No pipes, no sockets, no platform branching, no custom discovery endpoint. Just a real TCP port that any HTTP client already knows how to talk to.

I pulled the whole thing out into a standalone, open-source package — Umbraco.Community.WorktreeDevPort — precisely so I wouldn’t be tempted to copy-paste it into Umbraco.Automate too.

How to use it

For the .NET side:

dotnet add package Umbraco.Community.WorktreeDevPort

That’s it. It’s picked up automatically as an Umbraco composer and only activates in Development. No configuration required, though WorktreeDevPort:BasePort and WorktreeDevPort:RangeSize are there in appsettings.Development.json if you want to change the defaults.

For tooling — client generators, scripts, CI — there’s a matching npm package:

npm install worktree-dev-port
import { getPort } from "worktree-dev-port";

const port = getPort();
const spec = await fetch(`https://127.0.0.1:${port}/umbraco/swagger/<package>/swagger.json`);

Compare that to last time’s socket-path juggling. It’s just… fetch.

A couple of refinements that fell out along the way

Two things came up while actually using this day to day that I ended up handling in the package rather than working around each time:

The main checkout gets a familiar port. If you’re just working normally — no worktrees, one checkout — you don’t want the port to be some arbitrary number from a pool. So the package reserves a fixed port (44355 by default) specifically for the main checkout, and only hands it out when nothing else is using it. Linked worktrees skip straight past it into the auto-assigned pool, so it stays free for the checkout that actually wants it.

An explicit :0 port gets ignored, not honored. If a launch profile still has "applicationUrl": "https://127.0.0.1:0" lying around — the old “ask the OS for a random port” trick, which is exactly what this package exists to replace — the package now detects that and assigns its own stable port instead of quietly handing control back to a random one. Small thing, but it’s the kind of leftover config that’s easy to forget you have.

The bit I keep coming back to

In the original post I asked what other parts of our tooling still quietly assume a single developer, a single terminal, a single running instance. Named pipes were my answer at the time, and they weren’t wrong — they worked, and they taught me something. But they were also a custom-built answer to a problem git had already solved, sitting right there in a plumbing command I’d genuinely never had a reason to reach for before.

I don’t think the lesson is “named pipes were a bad idea.” I think it’s that the sign a solution has more moving parts than it needs isn’t that it doesn’t work — it’s that you find yourself explaining the same workaround to a second codebase. That’s usually when it’s worth asking whether the tool you’re reaching for has already solved the actual problem, one layer down.

If you’re wrangling the same multi-worktree, multi-agent port chaos, give it a try and let me know how it goes.

Until next time 👋