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:
Mika Kuns
2026-04-15 16:27:45 +02:00
parent d3b85f2234
commit 2b3fe02d8c
2 changed files with 130 additions and 66 deletions

View File

@@ -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()

View File

@@ -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}");
}
} }
} }