fix(worker): report startup port conflicts instead of crashing silently
Mutex was Local\ (per Windows session), so a second RDP/fast-user-switching session bypassed the single-instance guard. A Kestrel bind failure on top of that had no try/catch, so a second worker (or any process on 47821/47822) died as an unhandled crash with nothing in the log to explain why the UI never connects. Mutex is now Global\, bind failures and any last-resort unhandled exception log a Fatal line and exit(1) instead of crashing raw.
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
namespace ClaudeDo.Worker.Lifecycle;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats the Fatal log line for a startup/bind failure (e.g. a port already in use) so
|
||||||
|
/// Program.cs can log a diagnosable message and exit cleanly instead of crashing unhandled.
|
||||||
|
/// </summary>
|
||||||
|
public static class WorkerStartupFailure
|
||||||
|
{
|
||||||
|
public const int ExitCode = 1;
|
||||||
|
|
||||||
|
public static string FormatBindFailureMessage(Exception ex, int signalRPort, int externalMcpPort)
|
||||||
|
{
|
||||||
|
var ports = externalMcpPort > 0
|
||||||
|
? $"ports {signalRPort} and {externalMcpPort}"
|
||||||
|
: $"port {signalRPort}";
|
||||||
|
return $"ClaudeDo.Worker failed to start on {ports}: {ex.Message} " +
|
||||||
|
"Is another worker instance already running, or is another process using this port?";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,10 +31,11 @@ using ClaudeDo.Worker.Worktrees;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
// Single-instance per user session. Multiple launch paths exist (logon task,
|
// Single instance machine-wide. Multiple launch paths exist (logon task, app
|
||||||
// app ensure-running, Restart button); a second instance exits cleanly instead
|
// ensure-running, Restart button) and a Remote Desktop / Fast User Switching session
|
||||||
// of fighting over the SignalR port.
|
// gets its own `Local\` namespace, so `Global\` is required or a second session's
|
||||||
var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew);
|
// worker silently fights the first one over the SignalR port instead of exiting.
|
||||||
|
var mutex = new Mutex(true, @"Global\ClaudeDoWorker", out var createdNew);
|
||||||
if (!createdNew)
|
if (!createdNew)
|
||||||
return; // another instance already owns the port; exit 0
|
return; // another instance already owns the port; exit 0
|
||||||
|
|
||||||
@@ -65,6 +66,14 @@ builder.Host.UseSerilog((ctx, lc) => lc
|
|||||||
.WriteTo.Sink(broadcastSink));
|
.WriteTo.Sink(broadcastSink));
|
||||||
builder.Services.AddSingleton(logBuffer);
|
builder.Services.AddSingleton(logBuffer);
|
||||||
|
|
||||||
|
// Last-resort net: any exception that misses every other handler (e.g. a crash on a
|
||||||
|
// background thread) still gets one Fatal line in the log before the process dies.
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||||
|
{
|
||||||
|
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception, terminating (IsTerminating: {IsTerminating})", e.IsTerminating);
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
};
|
||||||
|
|
||||||
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
||||||
opt.UseSqlite($"Data Source={cfg.DbPath}"));
|
opt.UseSqlite($"Data Source={cfg.DbPath}"));
|
||||||
|
|
||||||
@@ -404,6 +413,8 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key");
|
string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
if (externalApp is null)
|
if (externalApp is null)
|
||||||
{
|
{
|
||||||
await app.RunAsync();
|
await app.RunAsync();
|
||||||
@@ -412,5 +423,14 @@ else
|
|||||||
{
|
{
|
||||||
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
|
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Most commonly a Kestrel bind failure (port already in use by a second session's
|
||||||
|
// worker or an unrelated process) — log it plainly instead of an unhandled crash.
|
||||||
|
Log.Fatal(ex, "{Message}", WorkerStartupFailure.FormatBindFailureMessage(ex, cfg.SignalRPort, cfg.ExternalMcpPort));
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
Environment.Exit(WorkerStartupFailure.ExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
GC.KeepAlive(mutex);
|
GC.KeepAlive(mutex);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using ClaudeDo.Worker.Lifecycle;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Lifecycle;
|
||||||
|
|
||||||
|
public sealed class WorkerStartupFailureTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FormatBindFailureMessage_IncludesBothPorts_WhenExternalMcpEnabled()
|
||||||
|
{
|
||||||
|
var message = WorkerStartupFailure.FormatBindFailureMessage(
|
||||||
|
new IOException("Failed to bind to address http://127.0.0.1:47821: address already in use."),
|
||||||
|
signalRPort: 47821,
|
||||||
|
externalMcpPort: 47822);
|
||||||
|
|
||||||
|
Assert.Contains("47821", message);
|
||||||
|
Assert.Contains("47822", message);
|
||||||
|
Assert.Contains("address already in use", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatBindFailureMessage_OmitsExternalPort_WhenDisabled()
|
||||||
|
{
|
||||||
|
var message = WorkerStartupFailure.FormatBindFailureMessage(
|
||||||
|
new IOException("boom"),
|
||||||
|
signalRPort: 47821,
|
||||||
|
externalMcpPort: 0);
|
||||||
|
|
||||||
|
Assert.Contains("47821", message);
|
||||||
|
Assert.DoesNotContain("0", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExitCode_IsNonZero()
|
||||||
|
{
|
||||||
|
Assert.NotEqual(0, WorkerStartupFailure.ExitCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user