Skip to content

Refuse to start a second instance unless asked to - #8

Merged
Wixely merged 1 commit into
mainfrom
feat/single-instance-guard
Aug 29, 2026
Merged

Wixely merged 1 commit into
mainfrom
feat/single-instance-guard

Conversation

@Wixely

@Wixely Wixely commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Why

A second MCPHub is not a harmless duplicate window. It binds the same fixed proxy port, starts every auto-start service on its own fixed port, and writes to the same shared servers folder. The two instances fight over all three, and the loser's failures read as unrelated server faults.

How

SingleInstanceGuard takes an exclusive OS lock on mcphub.lock in the per-user data directory and holds it for the life of the process -- an exclusive open on Windows, flock(LOCK_EX) on Unix, which keeps the linux-x64 build honest.

The lock is the mechanism, not the file's contents. The kernel releases it however the process ends -- kill, crash, power cut -- so there is no stale PID to second-guess and a leftover mcphub.lock never wedges a restart. The pid written into the file is diagnostic only; nothing reads it back to make a decision.

A file lock was chosen over a named Mutex because releases build for both win-x64 and linux-x64, and .NET's named mutexes behave differently on Unix.

Program.Main claims it before anything else, since by the time Avalonia is up the ports and services are already being taken. Main now returns int; a refused start writes the reason to standard error and exits 2.

The override

--allow-multiple-instances skips the guard entirely, for deliberately running a new build against an old one. Nothing else changes: both instances still compete for the port and the servers folder, so the second needs different settings first. Documented in Troubleshooting.

Tests

14 new tests, 140 passing overall. The guard lives in MCPHub.Core rather than MCPHub.App specifically so the test project can reach it.

Covered: acquire, refuse-while-held, release-and-reacquire, idempotent dispose, missing directory created, a stale lock file not blocking startup, and that near misses (--allow-multiple, allow-multiple-instances, --allow-multiple-instances=true) do not bypass the guard.

Also verified live on Windows: a stand-in process held the lock exclusively, then the real built MCPHub.exe was launched. Exit code 2, correct message on stderr, no window, no proxy, no services started.

Not verified end to end

The --allow-multiple-instances path was not exercised live. Doing so would start a genuine second hub binding port 5800 and spawning all nine services against the already-running instance -- the exact collision this change exists to prevent. It is covered by unit tests and is a three-line branch in Main.

Possible follow-up

With Close to tray on, the realistic case is double-clicking MCPHub while it sits in the tray, which now produces nothing visible -- stderr does not reach a double-click. Focusing the existing window instead of exiting quietly would be nicer, but needs IPC between instances, so it is left out of this change.

A second MCPHub is not a harmless duplicate window. It binds the same
fixed proxy port, starts every auto-start service on its own fixed port,
and writes to the same shared servers folder, so the two fight over all
three and the loser's failures read as unrelated server faults.

SingleInstanceGuard takes an exclusive OS lock on mcphub.lock in the
per-user data directory and holds it for the life of the process:
an exclusive open on Windows, flock(LOCK_EX) on Unix, which keeps the
Linux build honest. The kernel releases it however the process ends, so
there is no stale PID to second-guess and a leftover lock file never
blocks a restart. The pid written into the file is diagnostic only;
nothing reads it back to make a decision.

Program.Main claims it before anything else, because by the time Avalonia
is up the ports and services are already being taken. A refused start
writes the reason to standard error and exits 2. Pass
--allow-multiple-instances to start another instance deliberately.

The guard lives in Core rather than App so it can be tested: acquire,
refuse-while-held, release-and-reacquire, idempotent dispose, stale file,
missing directory, and that near misses like --allow-multiple do not
bypass it.
@Wixely
Wixely merged commit 94b2cb3 into main Aug 29, 2026
1 check passed
Wixely added a commit that referenced this pull request Sep 6, 2026
Refuse to start a second instance unless asked to
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants