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:
@@ -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,26 +97,37 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire live-log subscription to new task
|
_ = BindAsync(row, ct);
|
||||||
_subscribedTaskId = row.Id;
|
}
|
||||||
|
|
||||||
await using var ctx = _dbFactory.CreateDbContext();
|
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
|
||||||
var taskRepo = new TaskRepository(ctx);
|
{
|
||||||
var subtaskRepo = new SubtaskRepository(ctx);
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var taskRepo = new TaskRepository(ctx);
|
||||||
|
var subtaskRepo = new SubtaskRepository(ctx);
|
||||||
|
|
||||||
var entity = await taskRepo.GetByIdAsync(row.Id);
|
var entity = await taskRepo.GetByIdAsync(row.Id);
|
||||||
if (entity == null) return;
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (entity == null) return;
|
||||||
|
|
||||||
EditableTitle = entity.Title;
|
EditableTitle = entity.Title;
|
||||||
Notes = entity.Notes ?? "";
|
Notes = entity.Notes ?? "";
|
||||||
Model = entity.Model;
|
Model = entity.Model;
|
||||||
WorktreePath = entity.Worktree?.Path;
|
WorktreePath = entity.Worktree?.Path;
|
||||||
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();
|
||||||
|
|
||||||
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
|
// Subscribe only after DB load confirms the task exists
|
||||||
foreach (var s in subs)
|
_subscribedTaskId = row.Id;
|
||||||
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
|
||||||
|
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
foreach (var s in subs)
|
||||||
|
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||||
|
|||||||
@@ -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,27 +45,38 @@ 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);
|
||||||
var all = await db.Tasks
|
}
|
||||||
.Include(t => t.List)
|
|
||||||
.Include(t => t.Worktree)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
IEnumerable<TaskEntity> filtered = list.Kind switch
|
private async Task LoadForListAsync(ListNavItemViewModel list, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
|
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
|
var all = await db.Tasks
|
||||||
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
.Include(t => t.List)
|
||||||
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running),
|
.Include(t => t.Worktree)
|
||||||
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active),
|
.ToListAsync(ct);
|
||||||
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
|
|
||||||
_ => Enumerable.Empty<TaskEntity>(),
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var t in filtered)
|
ct.ThrowIfCancellationRequested();
|
||||||
Items.Add(TaskRowViewModel.FromEntity(t));
|
|
||||||
|
|
||||||
UpdateSubtitle();
|
IEnumerable<TaskEntity> filtered = list.Kind switch
|
||||||
|
{
|
||||||
|
ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay),
|
||||||
|
ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred),
|
||||||
|
ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null),
|
||||||
|
ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => t.Status == TaskStatus.Running),
|
||||||
|
ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active),
|
||||||
|
ListKind.User => all.Where(t => $"user:{t.ListId}" == list.Id),
|
||||||
|
_ => Enumerable.Empty<TaskEntity>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var t in filtered)
|
||||||
|
Items.Add(TaskRowViewModel.FromEntity(t));
|
||||||
|
|
||||||
|
UpdateSubtitle();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateSubtitle()
|
private void UpdateSubtitle()
|
||||||
|
|||||||
Reference in New Issue
Block a user