fix(ui): guard Bind/LoadForList against interleaved DbContext awaits

Added CancellationTokenSource per-load in both DetailsIslandViewModel
and TasksIslandViewModel. Public entry points cancel any in-flight load
before starting a new one. DB calls and collection mutations after awaits
are guarded with ThrowIfCancellationRequested. DetailsIslandViewModel
now sets _subscribedTaskId only after the DB confirms the entity exists,
preventing the SignalR handler from routing messages to a stale load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-20 11:00:58 +02:00
parent 279f2c7598
commit 62aac7eedb
2 changed files with 70 additions and 35 deletions

View File

@@ -41,6 +41,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// The task ID we are currently subscribed to for live log messages // The task ID we are currently subscribed to for live log messages
private string? _subscribedTaskId; private string? _subscribedTaskId;
private CancellationTokenSource? _loadCts;
// Set by the view so OpenDiffCommand can show the modal as a dialog // Set by the view so OpenDiffCommand can show the modal as a dialog
public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; } public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; }
@@ -72,8 +74,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
Log.Add(new LogLineViewModel { Kind = kind, Text = line }); Log.Add(new LogLineViewModel { Kind = kind, Text = line });
} }
public async void Bind(TaskRowViewModel? row) public void Bind(TaskRowViewModel? row)
{ {
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
Task = row; Task = row;
Log.Clear(); Log.Clear();
Subtasks.Clear(); Subtasks.Clear();
@@ -90,14 +97,19 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
return; return;
} }
// Wire live-log subscription to new task _ = BindAsync(row, ct);
_subscribedTaskId = row.Id; }
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
{
try
{
await using var ctx = _dbFactory.CreateDbContext(); await using var ctx = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(ctx); var taskRepo = new TaskRepository(ctx);
var subtaskRepo = new SubtaskRepository(ctx); var subtaskRepo = new SubtaskRepository(ctx);
var entity = await taskRepo.GetByIdAsync(row.Id); var entity = await taskRepo.GetByIdAsync(row.Id);
ct.ThrowIfCancellationRequested();
if (entity == null) return; if (entity == null) return;
EditableTitle = entity.Title; EditableTitle = entity.Title;
@@ -107,10 +119,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null; BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
AgentStatusLabel = entity.Status.ToString(); AgentStatusLabel = entity.Status.ToString();
// Subscribe only after DB load confirms the task exists
_subscribedTaskId = row.Id;
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id); var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
ct.ThrowIfCancellationRequested();
foreach (var s in subs) foreach (var s in subs)
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed }); Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
} }
catch (OperationCanceledException) { }
}
[RelayCommand(CanExecute = nameof(CanOpenDiff))] [RelayCommand(CanExecute = nameof(CanOpenDiff))]
private async System.Threading.Tasks.Task OpenDiffAsync() private async System.Threading.Tasks.Task OpenDiffAsync()

View File

@@ -12,6 +12,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private ListNavItemViewModel? _currentList; private ListNavItemViewModel? _currentList;
private CancellationTokenSource? _loadCts;
public event EventHandler? SelectionChanged; public event EventHandler? SelectionChanged;
public event EventHandler? FocusAddTaskRequested; public event EventHandler? FocusAddTaskRequested;
@@ -30,8 +31,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
_dbFactory = dbFactory; _dbFactory = dbFactory;
} }
public async void LoadForList(ListNavItemViewModel? list) public void LoadForList(ListNavItemViewModel? list)
{ {
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
_currentList = list; _currentList = list;
Items.Clear(); Items.Clear();
if (list is null) return; if (list is null) return;
@@ -39,11 +45,20 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
HeaderTitle = list.Name; HeaderTitle = list.Name;
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd").ToUpperInvariant(); HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd").ToUpperInvariant();
await using var db = await _dbFactory.CreateDbContextAsync(); _ = LoadForListAsync(list, ct);
}
private async Task LoadForListAsync(ListNavItemViewModel list, CancellationToken ct)
{
try
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
var all = await db.Tasks var all = await db.Tasks
.Include(t => t.List) .Include(t => t.List)
.Include(t => t.Worktree) .Include(t => t.Worktree)
.ToListAsync(); .ToListAsync(ct);
ct.ThrowIfCancellationRequested();
IEnumerable<TaskEntity> filtered = list.Kind switch IEnumerable<TaskEntity> filtered = list.Kind switch
{ {
@@ -61,6 +76,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
UpdateSubtitle(); UpdateSubtitle();
} }
catch (OperationCanceledException) { }
}
private void UpdateSubtitle() private void UpdateSubtitle()
{ {