fix(ui): surface worker connect/restart failures instead of no-opting

WorkerConnectionModalViewModel's StartWorker/RerunInstaller swallowed
missing-exe and Process.Start failures behind an empty catch and closed
the dialog anyway, looking like success. Now both show an inline error
and keep the dialog open; only a real launch closes it.

IslandsShellViewModel.RestartWorkerAsync never checked whether the
relaunched worker actually stayed up, and its status property was never
bound in any view. RestartWorkerService now fails if the new process
exits within a short survival window, and the outcome routes through
the footer error/success strip instead of the dead RestartWorkerStatus.

InstallArtifactLocator.Find() is now virtual and its two subclasses
unsealed so tests can fake exe resolution without touching the real
filesystem/registry.
This commit is contained in:
mika kuns
2026-09-10 13:34:32 +02:00
parent 46a0a27620
commit b4b6225432
8 changed files with 194 additions and 29 deletions
@@ -631,20 +631,25 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
await _updateCheck.CheckNowAsync(CancellationToken.None);
}
[ObservableProperty] private string? _restartWorkerStatus;
// How long to wait after launching the new worker process before deciding it survived
// (e.g. a port conflict kills it almost immediately — see #292 for the worker-side fix).
private const int RestartSurvivalCheckMs = 2000;
// Test seam: overrides the real kill-and-relaunch body, so tests can assert the
// success/failure footer routing without spawning a real worker process.
internal Func<Task>? RestartWorkerOperation { get; set; }
[RelayCommand]
private async Task RestartWorkerAsync()
{
RestartWorkerStatus = Loc.T("vm.shell.restartingWorker");
try
{
await Task.Run(RestartWorkerService);
await FlashRestartStatusAsync("Worker restarted.");
await (RestartWorkerOperation ?? (() => Task.Run(RestartWorkerService)))();
FlashFooterSuccess("Worker restarted.");
}
catch (Exception ex)
{
await FlashRestartStatusAsync($"Restart failed: {ex.Message}");
FlashFooterError($"Restart failed: {ex.Message} — check the worker log.");
}
}
@@ -670,14 +675,10 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
catch { /* may have exited or be inaccessible */ }
finally { p.Dispose(); }
}
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
}
private async Task FlashRestartStatusAsync(string text)
{
RestartWorkerStatus = text;
await Task.Delay(3000);
if (RestartWorkerStatus == text) RestartWorkerStatus = null;
var started = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
if (started is not null && started.WaitForExit(RestartSurvivalCheckMs))
throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})");
}
private bool _updatingClaudeCli;
@@ -1,7 +1,9 @@
using System;
using System.Diagnostics;
using System.IO;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals;
@@ -11,6 +13,9 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
private readonly WorkerLocator _workerLocator;
private readonly InstallerLocator _installerLocator;
// Test seam: real Process.Start by default, swappable to simulate a blocked/failed launch.
internal Action<ProcessStartInfo> ProcessStarter { get; set; } = psi => Process.Start(psi);
public WorkerConnectionModalViewModel(WorkerLocator workerLocator, InstallerLocator installerLocator)
{
_workerLocator = workerLocator;
@@ -19,15 +24,29 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
public Action? CloseAction { get; set; }
[ObservableProperty] private string? _errorText;
[RelayCommand] private void Close() => CloseAction?.Invoke();
[RelayCommand]
private void StartWorker()
{
var exe = _workerLocator.Find();
if (exe is null) return;
try { Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true }); }
catch { /* nothing useful to show */ }
if (exe is null)
{
ErrorText = Loc.T("modals.workerConnection.workerNotFound");
return;
}
try
{
ProcessStarter(new ProcessStartInfo(exe) { UseShellExecute = true });
}
catch (Exception ex)
{
ErrorText = Loc.T("modals.workerConnection.startFailed", ex.Message);
return;
}
ErrorText = null;
CloseAction?.Invoke();
}
@@ -35,18 +54,26 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
private void RerunInstaller()
{
var path = _installerLocator.Find();
if (path is null) return;
if (path is null)
{
ErrorText = Loc.T("modals.workerConnection.installerNotFound");
return;
}
try
{
// See IslandsShellViewModel.UpdateNow: an inherited CWD inside the install dir
// makes the installer block its own app\ rename.
Process.Start(new ProcessStartInfo(path)
ProcessStarter(new ProcessStartInfo(path)
{
UseShellExecute = true,
WorkingDirectory = Path.GetTempPath(),
});
Environment.Exit(0);
}
catch { /* nothing useful to show */ }
catch (Exception ex)
{
ErrorText = Loc.T("modals.workerConnection.startFailed", ex.Message);
return;
}
Environment.Exit(0);
}
}