fix(ui): auto-reconnect worker connection with retry backoff
Add IndefiniteRetryPolicy for WithAutomaticReconnect, wrap initial
StartAsync in a retry loop so the UI keeps retrying when Worker is
offline at startup, and expose IsReconnecting to StatusBar
("Connecting..." / "Online" / "Offline").
This commit is contained in:
@@ -7,13 +7,32 @@ namespace ClaudeDo.Ui.Services;
|
|||||||
|
|
||||||
public record ActiveTask(string Slot, string TaskId, DateTime StartedAt);
|
public record ActiveTask(string Slot, string TaskId, DateTime StartedAt);
|
||||||
|
|
||||||
|
sealed class IndefiniteRetryPolicy : IRetryPolicy
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan[] _delays =
|
||||||
|
[
|
||||||
|
TimeSpan.Zero,
|
||||||
|
TimeSpan.FromSeconds(2),
|
||||||
|
TimeSpan.FromSeconds(5),
|
||||||
|
TimeSpan.FromSeconds(10),
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
];
|
||||||
|
|
||||||
|
public TimeSpan? NextRetryDelay(RetryContext retryContext) =>
|
||||||
|
_delays[Math.Min(retryContext.PreviousRetryCount, _delays.Length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly HubConnection _hub;
|
private readonly HubConnection _hub;
|
||||||
|
private CancellationTokenSource? _startCts;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isConnected;
|
private bool _isConnected;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isReconnecting;
|
||||||
|
|
||||||
public ObservableCollection<ActiveTask> ActiveTasks { get; } = new();
|
public ObservableCollection<ActiveTask> ActiveTasks { get; } = new();
|
||||||
|
|
||||||
public event Action<string, string, DateTime>? TaskStartedEvent;
|
public event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
@@ -26,18 +45,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
_hub = new HubConnectionBuilder()
|
_hub = new HubConnectionBuilder()
|
||||||
.WithUrl(signalRUrl)
|
.WithUrl(signalRUrl)
|
||||||
.WithAutomaticReconnect()
|
.WithAutomaticReconnect(new IndefiniteRetryPolicy())
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
_hub.Reconnected += async _ =>
|
_hub.Reconnected += async _ =>
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() => IsConnected = true);
|
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
|
||||||
await SeedActiveTasksAsync();
|
await SeedActiveTasksAsync();
|
||||||
};
|
};
|
||||||
|
|
||||||
_hub.Reconnecting += _ =>
|
_hub.Reconnecting += _ =>
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() => IsConnected = false);
|
Dispatcher.UIThread.Post(() => { IsConnected = false; IsReconnecting = true; });
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,6 +65,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
Dispatcher.UIThread.Post(() =>
|
Dispatcher.UIThread.Post(() =>
|
||||||
{
|
{
|
||||||
IsConnected = false;
|
IsConnected = false;
|
||||||
|
IsReconnecting = false;
|
||||||
ActiveTasks.Clear();
|
ActiveTasks.Clear();
|
||||||
});
|
});
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -87,22 +107,43 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StartAsync()
|
public Task StartAsync()
|
||||||
{
|
{
|
||||||
try
|
_startCts = new CancellationTokenSource();
|
||||||
|
_ = ConnectWithRetryAsync(_startCts.Token);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConnectWithRetryAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var delays = new[] { 0, 2, 5, 10, 30 };
|
||||||
|
int attempt = 0;
|
||||||
|
Dispatcher.UIThread.Post(() => IsReconnecting = true);
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
await _hub.StartAsync();
|
try
|
||||||
Dispatcher.UIThread.Post(() => IsConnected = true);
|
{
|
||||||
await SeedActiveTasksAsync();
|
await _hub.StartAsync(ct);
|
||||||
}
|
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
|
||||||
catch
|
await SeedActiveTasksAsync();
|
||||||
{
|
return;
|
||||||
Dispatcher.UIThread.Post(() => IsConnected = false);
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
var delay = delays[Math.Min(attempt++, delays.Length - 1)];
|
||||||
|
try { await Task.Delay(TimeSpan.FromSeconds(delay), ct); }
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StopAsync()
|
public async Task StopAsync()
|
||||||
{
|
{
|
||||||
|
_startCts?.Cancel();
|
||||||
try { await _hub.StopAsync(); } catch { /* swallow */ }
|
try { await _hub.StopAsync(); } catch { /* swallow */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ public partial class StatusBarViewModel : ViewModelBase
|
|||||||
|
|
||||||
worker.PropertyChanged += (_, e) =>
|
worker.PropertyChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
if (e.PropertyName == nameof(WorkerClient.IsConnected) ||
|
||||||
|
e.PropertyName == nameof(WorkerClient.IsReconnecting))
|
||||||
{
|
{
|
||||||
ConnectionStatus = worker.IsConnected ? "Online" : "Offline";
|
ConnectionStatus = worker.IsConnected ? "Online"
|
||||||
|
: worker.IsReconnecting ? "Connecting..."
|
||||||
|
: "Offline";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user