From fdf357be8a195584bd1cb1526092b84d16fa7110 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 13 Apr 2026 14:50:34 +0200 Subject: [PATCH] fix(ui): harden worker auto-reconnect lifecycle - Fix _startCts lifecycle leak: old CTS is cancelled and disposed before replacement - Guard StartAsync re-entrancy: early-return if retry loop is still running (lock + IsCompleted check) - Await _retryLoopTask in both StopAsync and DisposeAsync before stopping/disposing the hub Co-Authored-By: Claude Sonnet 4.6 --- src/ClaudeDo.Ui/Services/WorkerClient.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 3570e9b..05458a9 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -26,6 +26,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable { private readonly HubConnection _hub; private CancellationTokenSource? _startCts; + private Task _retryLoopTask = Task.CompletedTask; + private readonly object _startLock = new(); [ObservableProperty] private bool _isConnected; @@ -109,8 +111,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable public Task StartAsync() { - _startCts = new CancellationTokenSource(); - _ = ConnectWithRetryAsync(_startCts.Token); + lock (_startLock) + { + if (!_retryLoopTask.IsCompleted) + return Task.CompletedTask; + + var old = _startCts; + _startCts = new CancellationTokenSource(); + old?.Cancel(); + old?.Dispose(); + + _retryLoopTask = ConnectWithRetryAsync(_startCts.Token); + } return Task.CompletedTask; } @@ -144,6 +156,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable public async Task StopAsync() { _startCts?.Cancel(); + try { await _retryLoopTask; } catch (OperationCanceledException) { } catch { /* swallow */ } try { await _hub.StopAsync(); } catch { /* swallow */ } } @@ -183,6 +196,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable public async ValueTask DisposeAsync() { _startCts?.Cancel(); + try { await _retryLoopTask; } catch (OperationCanceledException) { } catch { /* swallow */ } await _hub.DisposeAsync(); }