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,8 +22,19 @@ sealed class Program
|
||||
var factory = services.GetRequiredService<SqliteConnectionFactory>();
|
||||
SchemaInitializer.Apply(factory);
|
||||
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
try
|
||||
{
|
||||
BuildAvaloniaApp()
|
||||
.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()
|
||||
|
||||
@@ -55,6 +55,10 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
private string? _taskId;
|
||||
private string? _listId;
|
||||
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;
|
||||
|
||||
@@ -79,78 +83,98 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
|
||||
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;
|
||||
LiveText = "";
|
||||
_formatter = new StreamLineFormatter();
|
||||
|
||||
var task = await _taskRepo.GetByIdAsync(taskId);
|
||||
if (task is null) return;
|
||||
|
||||
if (AvailableAgents.Count == 0)
|
||||
{
|
||||
var agents = await _worker.GetAgentsAsync();
|
||||
AvailableAgents.AddRange(agents);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
_listId = task.ListId;
|
||||
Title = task.Title;
|
||||
Description = task.Description;
|
||||
Result = task.Result;
|
||||
LogPath = task.LogPath;
|
||||
if (task.LogPath is not null
|
||||
&& task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
|
||||
&& File.Exists(task.LogPath))
|
||||
var task = await _taskRepo.GetByIdAsync(taskId, ct);
|
||||
if (task is null) return;
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (AvailableAgents.Count == 0)
|
||||
{
|
||||
_formatter = new StreamLineFormatter();
|
||||
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath));
|
||||
var agents = await _worker.GetAgentsAsync();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
AvailableAgents.AddRange(agents);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
StatusText = task.Status.ToString().ToLowerInvariant();
|
||||
StatusChoice = task.Status.ToString();
|
||||
CommitType = task.CommitType;
|
||||
ModelChoice = task.Model is not null
|
||||
? ListEditorViewModel.ModelIdToDisplay(task.Model)
|
||||
: "(list default)";
|
||||
SystemPromptOverride = task.SystemPrompt;
|
||||
if (task.AgentPath is not null)
|
||||
|
||||
_isLoading = true;
|
||||
try
|
||||
{
|
||||
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath);
|
||||
if (match is null)
|
||||
_listId = task.ListId;
|
||||
Title = task.Title;
|
||||
Description = task.Description;
|
||||
Result = task.Result;
|
||||
LogPath = task.LogPath;
|
||||
if (task.LogPath is not null
|
||||
&& task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
|
||||
&& File.Exists(task.LogPath))
|
||||
{
|
||||
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath);
|
||||
AvailableAgents.Add(match);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
_formatter = new StreamLineFormatter();
|
||||
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct);
|
||||
}
|
||||
StatusText = task.Status.ToString().ToLowerInvariant();
|
||||
StatusChoice = task.Status.ToString();
|
||||
CommitType = task.CommitType;
|
||||
ModelChoice = task.Model is not null
|
||||
? ListEditorViewModel.ModelIdToDisplay(task.Model)
|
||||
: "(list default)";
|
||||
SystemPromptOverride = task.SystemPrompt;
|
||||
if (task.AgentPath is not null)
|
||||
{
|
||||
var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath);
|
||||
if (match is null)
|
||||
{
|
||||
match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath);
|
||||
AvailableAgents.Add(match);
|
||||
OnPropertyChanged(nameof(AvailableAgents));
|
||||
}
|
||||
SelectedAgent = match;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedAgent = null;
|
||||
}
|
||||
|
||||
Tags.Clear();
|
||||
var tags = await _taskRepo.GetTagsAsync(taskId, ct);
|
||||
foreach (var tag in tags)
|
||||
Tags.Add(tag);
|
||||
|
||||
// Tear down old subtask subscriptions before replacing them.
|
||||
foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged;
|
||||
Subtasks.Clear();
|
||||
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId, ct);
|
||||
foreach (var s in subtasks)
|
||||
{
|
||||
var vm = SubtaskItemViewModel.From(s);
|
||||
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
||||
Subtasks.Add(vm);
|
||||
}
|
||||
SelectedAgent = match;
|
||||
}
|
||||
else
|
||||
finally
|
||||
{
|
||||
SelectedAgent = null;
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
Tags.Clear();
|
||||
var tags = await _taskRepo.GetTagsAsync(taskId);
|
||||
foreach (var tag in tags)
|
||||
Tags.Add(tag);
|
||||
|
||||
Subtasks.Clear();
|
||||
var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId);
|
||||
foreach (var s in subtasks)
|
||||
{
|
||||
var vm = SubtaskItemViewModel.From(s);
|
||||
vm.PropertyChanged += OnSubtaskPropertyChanged;
|
||||
Subtasks.Add(vm);
|
||||
}
|
||||
await LoadWorktreeAsync(taskId);
|
||||
}
|
||||
finally
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_isLoading = false;
|
||||
// Superseded by a newer LoadAsync — nothing to do.
|
||||
}
|
||||
|
||||
await LoadWorktreeAsync(taskId);
|
||||
}
|
||||
|
||||
public async Task SaveAsync()
|
||||
@@ -236,15 +260,23 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
{
|
||||
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;
|
||||
await _subtaskRepo.UpdateAsync(new SubtaskEntity
|
||||
try
|
||||
{
|
||||
Id = vm.Id,
|
||||
TaskId = _taskId ?? "",
|
||||
Title = vm.Title,
|
||||
Completed = vm.Completed,
|
||||
OrderNum = Subtasks.IndexOf(vm),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await _subtaskRepo.UpdateAsync(new SubtaskEntity
|
||||
{
|
||||
Id = vm.Id,
|
||||
TaskId = _taskId ?? "",
|
||||
Title = vm.Title,
|
||||
Completed = vm.Completed,
|
||||
OrderNum = Subtasks.IndexOf(vm),
|
||||
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)
|
||||
@@ -261,6 +293,11 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
// Cancel any load in flight so it doesn't resurrect state after Clear.
|
||||
_loadCts?.Cancel();
|
||||
_loadCts?.Dispose();
|
||||
_loadCts = null;
|
||||
|
||||
_taskId = null;
|
||||
_listId = null;
|
||||
Title = "";
|
||||
@@ -408,12 +445,28 @@ public partial class TaskDetailViewModel : ViewModelBase
|
||||
private async void OnWorktreeUpdated(string taskId)
|
||||
{
|
||||
if (taskId != _taskId) return;
|
||||
await LoadWorktreeAsync(taskId);
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (taskId != _taskId) return;
|
||||
await LoadAsync(taskId);
|
||||
try
|
||||
{
|
||||
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