fix(claude-do): merge fix(worker): Port-Konflikt beim Start laut melden statt stil

ClaudeDo-Task: 1eb4cfcd-a31e-4216-8c05-88cd662a211b
This commit is contained in:
mika kuns
2026-09-10 13:33:28 +02:00
3 changed files with 85 additions and 8 deletions
@@ -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?";
}
}
+28 -8
View File
@@ -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,13 +413,24 @@ if (cfg.ExternalMcpPort > 0)
string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key"); string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key");
} }
if (externalApp is null) try
{ {
await app.RunAsync(); if (externalApp is null)
{
await app.RunAsync();
}
else
{
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
}
} }
else catch (Exception ex)
{ {
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync()); // 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);
}
}