fix(ui): prevent async void races and leak-on-exit
- task detail vm: LoadAsync now uses a per-call CancellationTokenSource so rapid TaskUpdated events can't race on _taskId / Subtasks / Tags; old subtask PropertyChanged handlers are torn down before Clear - task detail vm: async void event handlers (OnTaskUpdated, OnWorktreeUpdated, OnSubtaskPropertyChanged) wrap work in try/catch so thrown exceptions can't crash the Avalonia sync context - task detail vm: Clear cancels/disposes the load CTS so a late-arriving LoadAsync can't resurrect detail state after deselect - app: DisposeAsync the ServiceProvider in a finally after the classic desktop lifetime ends, so WorkerClient.DisposeAsync runs and the SignalR connection closes cleanly instead of being abandoned Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,9 +22,20 @@ sealed class Program
|
|||||||
var factory = services.GetRequiredService<SqliteConnectionFactory>();
|
var factory = services.GetRequiredService<SqliteConnectionFactory>();
|
||||||
SchemaInitializer.Apply(factory);
|
SchemaInitializer.Apply(factory);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
BuildAvaloniaApp()
|
BuildAvaloniaApp()
|
||||||
.StartWithClassicDesktopLifetime(args);
|
.StartWithClassicDesktopLifetime(args);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Dispose the container so WorkerClient.DisposeAsync runs —
|
||||||
|
// cancels the retry loop and closes the SignalR connection cleanly
|
||||||
|
// instead of abandoning it.
|
||||||
|
try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
|
||||||
|
catch { /* best effort on shutdown */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static AppBuilder BuildAvaloniaApp()
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
=> AppBuilder.Configure<App>()
|
=> AppBuilder.Configure<App>()
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
private string? _taskId;
|
private string? _taskId;
|
||||||
private string? _listId;
|
private string? _listId;
|
||||||
private bool _isLoading;
|
private bool _isLoading;
|
||||||
|
// Cancels an in-flight LoadAsync when a new TaskUpdated event arrives
|
||||||
|
// before the previous load finished — prevents torn state on _taskId,
|
||||||
|
// Subtasks, Tags, etc.
|
||||||
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
public event Action<string>? TaskChanged;
|
public event Action<string>? TaskChanged;
|
||||||
|
|
||||||
@@ -79,16 +83,29 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
public async Task LoadAsync(string taskId)
|
public async Task LoadAsync(string taskId)
|
||||||
{
|
{
|
||||||
|
// Cancel any in-flight load so rapid TaskUpdated events don't race
|
||||||
|
// on _taskId / Subtasks / Tags. The newest caller wins.
|
||||||
|
var oldCts = _loadCts;
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_loadCts = cts;
|
||||||
|
oldCts?.Cancel();
|
||||||
|
oldCts?.Dispose();
|
||||||
|
var ct = cts.Token;
|
||||||
|
|
||||||
_taskId = taskId;
|
_taskId = taskId;
|
||||||
LiveText = "";
|
LiveText = "";
|
||||||
_formatter = new StreamLineFormatter();
|
_formatter = new StreamLineFormatter();
|
||||||
|
|
||||||
var task = await _taskRepo.GetByIdAsync(taskId);
|
try
|
||||||
|
{
|
||||||
|
var task = await _taskRepo.GetByIdAsync(taskId, ct);
|
||||||
if (task is null) return;
|
if (task is null) return;
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (AvailableAgents.Count == 0)
|
if (AvailableAgents.Count == 0)
|
||||||
{
|
{
|
||||||
var agents = await _worker.GetAgentsAsync();
|
var agents = await _worker.GetAgentsAsync();
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
AvailableAgents.AddRange(agents);
|
AvailableAgents.AddRange(agents);
|
||||||
OnPropertyChanged(nameof(AvailableAgents));
|
OnPropertyChanged(nameof(AvailableAgents));
|
||||||
}
|
}
|
||||||
@@ -106,7 +123,7 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
&& File.Exists(task.LogPath))
|
&& File.Exists(task.LogPath))
|
||||||
{
|
{
|
||||||
_formatter = new StreamLineFormatter();
|
_formatter = new StreamLineFormatter();
|
||||||
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath));
|
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct);
|
||||||
}
|
}
|
||||||
StatusText = task.Status.ToString().ToLowerInvariant();
|
StatusText = task.Status.ToString().ToLowerInvariant();
|
||||||
StatusChoice = task.Status.ToString();
|
StatusChoice = task.Status.ToString();
|
||||||
@@ -132,12 +149,14 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
Tags.Clear();
|
Tags.Clear();
|
||||||
var tags = await _taskRepo.GetTagsAsync(taskId);
|
var tags = await _taskRepo.GetTagsAsync(taskId, ct);
|
||||||
foreach (var tag in tags)
|
foreach (var tag in tags)
|
||||||
Tags.Add(tag);
|
Tags.Add(tag);
|
||||||
|
|
||||||
|
// Tear down old subtask subscriptions before replacing them.
|
||||||
|
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||||
Subtasks.Clear();
|
Subtasks.Clear();
|
||||||
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId);
|
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId, ct);
|
||||||
foreach (var s in subtasks)
|
foreach (var s in subtasks)
|
||||||
{
|
{
|
||||||
var vm = SubtaskItemViewModel.From(s);
|
var vm = SubtaskItemViewModel.From(s);
|
||||||
@@ -152,6 +171,11 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
await LoadWorktreeAsync(taskId);
|
await LoadWorktreeAsync(taskId);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Superseded by a newer LoadAsync — nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SaveAsync()
|
public async Task SaveAsync()
|
||||||
{
|
{
|
||||||
@@ -236,6 +260,8 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return;
|
if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return;
|
||||||
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
|
if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
await _subtaskRepo.UpdateAsync(new SubtaskEntity
|
await _subtaskRepo.UpdateAsync(new SubtaskEntity
|
||||||
{
|
{
|
||||||
Id = vm.Id,
|
Id = vm.Id,
|
||||||
@@ -246,6 +272,12 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// async void must never throw — surface via Debug.
|
||||||
|
Debug.WriteLine($"[TaskDetailViewModel] Subtask update failed for {vm.Id}: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void SetAgentFromPath(string path)
|
public void SetAgentFromPath(string path)
|
||||||
{
|
{
|
||||||
@@ -261,6 +293,11 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
|
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
|
// Cancel any load in flight so it doesn't resurrect state after Clear.
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_loadCts?.Dispose();
|
||||||
|
_loadCts = null;
|
||||||
|
|
||||||
_taskId = null;
|
_taskId = null;
|
||||||
_listId = null;
|
_listId = null;
|
||||||
Title = "";
|
Title = "";
|
||||||
@@ -408,12 +445,28 @@ public partial class TaskDetailViewModel : ViewModelBase
|
|||||||
private async void OnWorktreeUpdated(string taskId)
|
private async void OnWorktreeUpdated(string taskId)
|
||||||
{
|
{
|
||||||
if (taskId != _taskId) return;
|
if (taskId != _taskId) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
await LoadWorktreeAsync(taskId);
|
await LoadWorktreeAsync(taskId);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// async void must never throw.
|
||||||
|
Debug.WriteLine($"[TaskDetailViewModel] OnWorktreeUpdated failed for {taskId}: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnTaskUpdated(string taskId)
|
private async void OnTaskUpdated(string taskId)
|
||||||
{
|
{
|
||||||
if (taskId != _taskId) return;
|
if (taskId != _taskId) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
await LoadAsync(taskId);
|
await LoadAsync(taskId);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// async void must never throw.
|
||||||
|
Debug.WriteLine($"[TaskDetailViewModel] OnTaskUpdated failed for {taskId}: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user