diff --git a/src/ClaudeDo.Worker/Lifecycle/WorkerStartupFailure.cs b/src/ClaudeDo.Worker/Lifecycle/WorkerStartupFailure.cs new file mode 100644 index 00000000..1bb5615f --- /dev/null +++ b/src/ClaudeDo.Worker/Lifecycle/WorkerStartupFailure.cs @@ -0,0 +1,19 @@ +namespace ClaudeDo.Worker.Lifecycle; + +/// +/// 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. +/// +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?"; + } +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index a1ba39a7..7724a690 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -31,10 +31,11 @@ using ClaudeDo.Worker.Worktrees; using Microsoft.EntityFrameworkCore; using Serilog; -// Single-instance per user session. Multiple launch paths exist (logon task, -// app ensure-running, Restart button); a second instance exits cleanly instead -// of fighting over the SignalR port. -var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew); +// Single instance machine-wide. Multiple launch paths exist (logon task, app +// ensure-running, Restart button) and a Remote Desktop / Fast User Switching session +// gets its own `Local\` namespace, so `Global\` is required or a second session's +// 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) return; // another instance already owns the port; exit 0 @@ -65,6 +66,14 @@ builder.Host.UseSerilog((ctx, lc) => lc .WriteTo.Sink(broadcastSink)); 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(opt => opt.UseSqlite($"Data Source={cfg.DbPath}")); @@ -404,13 +413,24 @@ if (cfg.ExternalMcpPort > 0) 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); diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/WorkerStartupFailureTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/WorkerStartupFailureTests.cs new file mode 100644 index 00000000..40ce2da2 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/WorkerStartupFailureTests.cs @@ -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); + } +}