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>();
|
var factory = services.GetRequiredService<SqliteConnectionFactory>();
|
||||||
SchemaInitializer.Apply(factory);
|
SchemaInitializer.Apply(factory);
|
||||||
|
|
||||||
BuildAvaloniaApp()
|
try
|
||||||
.StartWithClassicDesktopLifetime(args);
|
{
|
||||||
|
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()
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
|
|||||||
@@ -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,78 +83,98 @@ 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);
|
|
||||||
if (task is null) return;
|
|
||||||
|
|
||||||
if (AvailableAgents.Count == 0)
|
|
||||||
{
|
|
||||||
var agents = await _worker.GetAgentsAsync();
|
|
||||||
AvailableAgents.AddRange(agents);
|
|
||||||
OnPropertyChanged(nameof(AvailableAgents));
|
|
||||||
}
|
|
||||||
|
|
||||||
_isLoading = true;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_listId = task.ListId;
|
var task = await _taskRepo.GetByIdAsync(taskId, ct);
|
||||||
Title = task.Title;
|
if (task is null) return;
|
||||||
Description = task.Description;
|
ct.ThrowIfCancellationRequested();
|
||||||
Result = task.Result;
|
|
||||||
LogPath = task.LogPath;
|
if (AvailableAgents.Count == 0)
|
||||||
if (task.LogPath is not null
|
|
||||||
&& task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed
|
|
||||||
&& File.Exists(task.LogPath))
|
|
||||||
{
|
{
|
||||||
_formatter = new StreamLineFormatter();
|
var agents = await _worker.GetAgentsAsync();
|
||||||
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath));
|
ct.ThrowIfCancellationRequested();
|
||||||
|
AvailableAgents.AddRange(agents);
|
||||||
|
OnPropertyChanged(nameof(AvailableAgents));
|
||||||
}
|
}
|
||||||
StatusText = task.Status.ToString().ToLowerInvariant();
|
|
||||||
StatusChoice = task.Status.ToString();
|
_isLoading = true;
|
||||||
CommitType = task.CommitType;
|
try
|
||||||
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);
|
_listId = task.ListId;
|
||||||
if (match is null)
|
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);
|
_formatter = new StreamLineFormatter();
|
||||||
AvailableAgents.Add(match);
|
LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct);
|
||||||
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)
|
||||||
|
{
|
||||||
|
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();
|
await LoadWorktreeAsync(taskId);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
_isLoading = false;
|
// Superseded by a newer LoadAsync — nothing to do.
|
||||||
}
|
}
|
||||||
|
|
||||||
await LoadWorktreeAsync(taskId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SaveAsync()
|
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 (_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;
|
||||||
await _subtaskRepo.UpdateAsync(new SubtaskEntity
|
try
|
||||||
{
|
{
|
||||||
Id = vm.Id,
|
await _subtaskRepo.UpdateAsync(new SubtaskEntity
|
||||||
TaskId = _taskId ?? "",
|
{
|
||||||
Title = vm.Title,
|
Id = vm.Id,
|
||||||
Completed = vm.Completed,
|
TaskId = _taskId ?? "",
|
||||||
OrderNum = Subtasks.IndexOf(vm),
|
Title = vm.Title,
|
||||||
CreatedAt = DateTime.UtcNow,
|
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)
|
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;
|
||||||
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)
|
private async void OnTaskUpdated(string taskId)
|
||||||
{
|
{
|
||||||
if (taskId != _taskId) return;
|
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