fix(ui): unfreeze the operation spinner and attribute rebind churn

Three causes behind the stuck indicator:
- OperationStatus.End did not retire the generation, so tick work posted
  while the dispatcher was blocked drained afterwards and set
  ShowIndicator back to true on a finished operation.
- The startup update check ran in Task.Run; OperationStatus writes its
  observable properties on the calling thread, so the final
  ShowIndicator=false never reached the binding.
- OperationIndicator overwrote its own DataContext from the Status
  property, which re-targeted the call-site binding. It now scopes the
  DataContext to an inner panel and collapses itself when Status is
  null; every call site switched from DataContext= to Status=.

Also gates the footer connection pill in the command body instead of
CanExecute (a disabled command greyed out the ONLINE chip), and extends
OperationTiming: pid per line, 4 MB rollover, fast successes dropped
(failures always kept), and DetailsIsland.BindAsync now carries the
selection trigger via TasksIslandViewModel.SelectFrom.
This commit is contained in:
mika kuns
2026-08-12 13:50:05 +02:00
parent b9827eac01
commit 589b9e75f3
19 changed files with 198 additions and 38 deletions
+3 -1
View File
@@ -89,6 +89,7 @@ new editor boilerplate there rather than copying it a third time.
- **IPrimeScheduleApi** — prime-schedule CRUD.
- **UpdateCheckService** — polls releases; `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` feed the shell's update banner.
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
- **OperationTiming** — NDJSON sink (`~/.todo-app/logs/operation-timing.ndjson`, one rolled `.1` at 4 MB) behind every hub invoke and bulk DB path; each line carries `pid` because app restarts interleave in one file. Successful calls under 25 ms are dropped, **failed/cancelled ones always land** — a 1 ms cancelled `BindAsync` is churn signal, not noise. `DetailsIsland.BindAsync:<source>` carries the selection trigger (`TasksIslandViewModel.SelectionSource`, set via `SelectFrom` — never assign `SelectedTask` directly), because the first day of data showed ~1000 binds in 3.5 h, 91% cancelled, with an unexplained trigger.
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.
## Converters
@@ -118,6 +119,7 @@ snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by
- Context menus exist on both list and task rows; right-click selects before opening the menu.
- "Run Now" CanExecute re-evaluates when worker connection state changes.
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner (starting ConPTY pane, refining task row).
- **Every `IWorkerClient` call in a `[RelayCommand]` runs through an `OperationStatus` (`Services/OperationStatus.cs`), shown via the `OperationIndicator` control** — never a handcrafted spinner. Without it the spinner `StackPanel` from `MergeModalView.axaml` gets rebuilt at every call site, and `IsBusy` flags lock the button but show nothing.
- **Never set a `UserControl`'s own `DataContext` from a `StyledProperty`.** The property binding at the call site (`Status="{Binding RejectOp}"`) resolves *against that control's DataContext* — overwriting it re-targets the binding at the new value and the compiled getter throws `InvalidCastException`. Scope the DataContext to an inner panel instead (`DataContext="{Binding #Root.Status}"`, see `OperationIndicator.axaml`). Corollary: **the inner panel's DataContext is then null whenever the property is null**, so `IsVisible="{Binding ShowIndicator}"` can't resolve and falls back to the property default `true` — a permanent label-less spinner *plus* the stalled hint. `OperationIndicator` therefore collapses itself in code-behind when `Status` is null; wire every call site with `Status=`, never `DataContext=`.
- **Every `IWorkerClient` call in a `[RelayCommand]` runs through an `OperationStatus` (`Services/OperationStatus.cs`), shown via the `OperationIndicator` control** — never a handcrafted spinner. Without it the spinner `StackPanel` from `MergeModalView.axaml` gets rebuilt at every call site, and `IsBusy` flags lock the button but show nothing. **`Begin`/`Report`/`Dispose` must run on the UI thread** — only the tick is marshalled, so a `Task.Run`-wrapped call leaves the bindings updating off-thread (the startup update check did this). A `[RelayCommand]` satisfies this on its own. Separately, **`End` retires the generation** so tick work that was posted while the dispatcher was blocked can't drain afterwards and set `ShowIndicator` back to true on a finished operation — that was the real frozen-spinner bug, and the enabling condition (~2.4s blocked UI thread during window construction) is still there.
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) — used for both the task `Log` and the prep `PrepLog`.
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" overlay; `DescriptionStepsCard` shows the attachments list, an "Add file…" picker, and an explicit `DropStatus` line. Keys use the `details.attachments.*` locale namespace (en + de).
@@ -7,6 +7,11 @@ namespace ClaudeDo.Ui.Services;
/// independent instances per ViewModel are expected (one per command) — there is no shared or
/// static state, which is exactly what a static DispatcherTimer would introduce and the cause of
/// the order-dependent flakiness in Ui.Tests.
///
/// **Call Begin/Report/Dispose from the UI thread.** Only the timer tick is marshalled; the other
/// three write their observable properties inline on the calling thread, so an off-thread caller
/// leaves the bindings updating off the UI thread. A [RelayCommand] satisfies this by itself (its
/// awaits resume on the UI thread); wrapping the call in Task.Run breaks it.
public sealed partial class OperationStatus : ObservableObject
{
private static readonly TimeSpan GracePeriod = TimeSpan.FromMilliseconds(300);
@@ -79,6 +84,12 @@ public sealed partial class OperationStatus : ObservableObject
private void End(int generation)
{
if (generation != _generation) return;
// Retire the generation *before* touching the flags. The timer callback fires on a
// threadpool thread and only posts its UI work, so a tick that fired while the dispatcher
// was busy can drain *after* End — with the generation still current it would set
// ShowIndicator back to true on an already-finished operation, and since the timer is gone
// by then no later tick ever clears it. That is the frozen spinner.
_generation++;
_tickTimer?.Dispose();
_tickTimer = null;
IsRunning = false;
+34 -2
View File
@@ -10,26 +10,49 @@ namespace ClaudeDo.Ui.Services;
/// </summary>
public sealed class OperationTiming
{
/// <summary>Fast successful operations are dropped: the first day of data was 43% 3ms reconcile
/// ticks, which crowd out the outliers the file exists to surface. Failed/cancelled operations
/// are always kept regardless of duration — a 1ms cancelled call is a churn signal, not noise.</summary>
public const int DefaultMinMs = 25;
/// <summary>One rollover file is kept (<c>.1</c>); at ~1000 lines/h a 4 MB cap holds weeks.</summary>
public const long DefaultMaxBytes = 4L * 1024 * 1024;
public static string DefaultPath =>
Path.Combine(Paths.AppDataRoot(), "logs", "operation-timing.ndjson");
public static OperationTiming Shared { get; } = new(DefaultPath);
private static readonly int ProcessId = Environment.ProcessId;
private readonly string _path;
private readonly int _minMs;
private readonly long _maxBytes;
private readonly object _writeLock = new();
public OperationTiming(string filePath) => _path = filePath;
public OperationTiming(string filePath, int minMs = DefaultMinMs, long maxBytes = DefaultMaxBytes)
{
_path = filePath;
_minMs = minMs;
_maxBytes = maxBytes;
}
public void Record(string kind, string operation, TimeSpan elapsed, bool ok)
{
try
{
var ms = (long)elapsed.TotalMilliseconds;
if (ok && ms < _minMs) return;
var line = JsonSerializer.Serialize(new
{
ts = DateTime.UtcNow,
// Runs of the app interleave in one file; without the pid, a restart looks like
// concurrent work by a single instance.
pid = ProcessId,
kind,
op = operation,
ms = (long)elapsed.TotalMilliseconds,
ms,
ok,
});
@@ -38,6 +61,7 @@ public sealed class OperationTiming
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
Roll();
File.AppendAllText(_path, line + Environment.NewLine);
}
}
@@ -46,4 +70,12 @@ public sealed class OperationTiming
// A measurement must never disturb the app.
}
}
private void Roll()
{
if (!File.Exists(_path) || new FileInfo(_path).Length < _maxBytes) return;
var rolled = _path + ".1";
File.Delete(rolled);
File.Move(_path, rolled);
}
}
@@ -578,7 +578,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public void ShowNotes()
{
Bind(null);
Bind(null, "notes");
IsPrepMode = false;
IsNotesMode = true;
_ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
@@ -586,13 +586,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public void ShowPrep()
{
Bind(null);
Bind(null, "prep");
IsNotesMode = false;
IsPrepMode = true;
_ = Prep.LoadLastPrepLogIfEmptyAsync();
}
public void Bind(TaskRowViewModel? row)
/// <param name="source">What triggered the selection (<see cref="TasksIslandViewModel.SelectionSource"/>);
/// logged with the bind timing so rebind churn can be attributed to a trigger.</param>
public void Bind(TaskRowViewModel? row, string source = "?")
{
IsNotesMode = false;
IsPrepMode = false;
@@ -634,10 +636,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// agent output, so the Output tab would open empty -- its work shows up as git changes.
SelectedTab = row.IsManual || row.HasInteractiveSession ? "git" : "output";
_ = BindAsync(row, ct);
_ = BindAsync(row, ct, source);
}
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct, string source)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
@@ -735,7 +737,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
ok = true;
}
catch (OperationCanceledException) { }
finally { OperationTiming.Shared.Record("db", "DetailsIsland.BindAsync", sw.Elapsed, ok); }
finally { OperationTiming.Shared.Record("db", $"DetailsIsland.BindAsync:{source}", sw.Elapsed, ok); }
}
private async System.Threading.Tasks.Task LoadChildOutcomesAsync(string parentTaskId, CancellationToken ct)
@@ -47,6 +47,19 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
public event EventHandler? SelectionChanged;
/// <summary>Why the selection last changed. Recorded with the detail-pane bind, so the timing
/// log attributes rebind churn to a trigger instead of leaving an anonymous count.</summary>
public string SelectionSource { get; private set; } = "init";
/// <summary>Sets <see cref="SelectedTask"/> and tags what triggered it — use this instead of
/// assigning the property, otherwise the bind is logged as "?" .</summary>
public void SelectFrom(TaskRowViewModel? row, string source)
{
SelectionSource = source;
SelectedTask = row;
}
public event EventHandler? FocusAddTaskRequested;
public event EventHandler? TasksChanged;
public event Action? NotesRequested;
@@ -57,7 +70,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[RelayCommand]
private void OpenNotes()
{
SelectedTask = null;
SelectFrom(null, "notes");
NotesRequested?.Invoke();
}
@@ -359,7 +372,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
HasCompleted = false;
ShowOpenLabel = false;
ShowNotesRow = false;
if (listChanged) SelectedTask = null;
if (listChanged) SelectFrom(null, "list-change");
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
HeaderTitle = list.Name;
@@ -772,7 +785,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
Items.Add(row);
Regroup();
SelectedTask = row;
SelectFrom(row, "new-task");
NewTaskTitle = "";
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
@@ -1272,7 +1285,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);
[RelayCommand]
private void Select(TaskRowViewModel row) => SelectedTask = row;
private void Select(TaskRowViewModel row) => SelectFrom(row, "row-click");
public async System.Threading.Tasks.Task<bool> SelectByIdAsync(string taskId)
{
@@ -1282,7 +1295,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is null) return false;
SelectedTask = row;
SelectFrom(row, "select-by-id");
return true;
}
@@ -303,7 +303,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_git = git;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource);
Tasks.NotesRequested += () => Details.ShowNotes();
Tasks.PrepRequested += () => Details.ShowPrep();
Tasks.ErrorReported += FlashFooterError;
@@ -340,7 +340,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Lists.LetClaudeHandleListCommand.Execute(row);
};
Details.ErrorReported += FlashFooterError;
Details.CloseDetail = () => Tasks.SelectedTask = null;
Details.CloseDetail = () => Tasks.SelectFrom(null, "close-detail");
Details.DeleteFromList = row =>
{
Tasks.LoadForList(Lists.SelectedList);
@@ -354,7 +354,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
OnPropertyChanged(nameof(ConnectionText));
OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(CanOpenWorkerConnectionHelp));
OpenWorkerConnectionHelpCommand.NotifyCanExecuteChanged();
}
};
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
@@ -390,11 +389,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) CheckForUpdatesCommand.NotifyCanExecuteChanged();
};
// Fire-and-forget startup check — never block UI.
_ = Task.Run(async () =>
{
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
});
// Fire-and-forget startup check — never block UI. Deliberately *not* Task.Run: the check is
// pure async I/O (no sync blocking), and `OperationStatus` writes its observable properties
// on the calling thread. From a threadpool thread the final `ShowIndicator=false` never
// reaches the binding, so the indicator froze on the last tick ("Checking… 00:03").
_ = StartupUpdateCheckAsync();
}
private async Task StartupUpdateCheckAsync()
{
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
}
public void Dispose()
@@ -526,8 +530,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
if (Dialogs is not null) await Dialogs.ShowWorkerConnectionAsync(vm);
}
[RelayCommand(CanExecute = nameof(CanOpenWorkerConnectionHelp))]
private Task OpenWorkerConnectionHelp() => OpenWorkerConnectionHelpAsync();
// The gate lives in the body, not in CanExecute: the footer pill *is* this button, so a
// disabled command renders the "ONLINE" chip greyed out — which reads as a broken connection,
// the opposite of what it says.
[RelayCommand]
private Task OpenWorkerConnectionHelp() =>
CanOpenWorkerConnectionHelp ? OpenWorkerConnectionHelpAsync() : Task.CompletedTask;
[RelayCommand]
private async Task OpenRepoImport()
@@ -3,8 +3,13 @@
xmlns:svc="using:ClaudeDo.Ui.Services"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Controls.OperationIndicator"
x:Name="Root"
x:DataType="svc:OperationStatus">
<!-- DataContext is scoped to the inner panel, never to the UserControl itself: overriding the
control's own DataContext would re-target the `Status="{Binding ...}"` binding at every call
site (it resolves against this control's DataContext) and throw an InvalidCastException. -->
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" VerticalAlignment="Center"
DataContext="{Binding #Root.Status}"
IsVisible="{Binding ShowIndicator}">
<Ellipse Classes="spinner" VerticalAlignment="Center"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding Label}"/>
@@ -15,12 +15,21 @@ public partial class OperationIndicator : UserControl
set => SetValue(StatusProperty, value);
}
public OperationIndicator() => InitializeComponent();
public OperationIndicator()
{
InitializeComponent();
IsVisible = false;
}
/// A null `Status` leaves the inner panel's DataContext null, so its `IsVisible="{Binding
/// ShowIndicator}"` can't resolve and falls back to the property default — *visible*. That
/// renders a label-less spinner plus the stalled hint forever. Collapse the whole control
/// instead. Deliberately a direct set, not a binding: no call site binds `IsVisible` on the
/// indicator, and a local value would win over one anyway.
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == StatusProperty)
DataContext = change.GetNewValue<OperationStatus?>();
IsVisible = change.GetNewValue<OperationStatus?>() is not null;
}
}
@@ -160,7 +160,7 @@
Command="{Binding Prep.PlanDayCommand}"
IsEnabled="{Binding Prep.IsPlanDayEnabled}"
Content="{loc:Tr details.planDay}"/>
<ctl:OperationIndicator DataContext="{Binding Prep.PrepOperation}"/>
<ctl:OperationIndicator Status="{Binding Prep.PrepOperation}"/>
</StackPanel>
</Border>
<Panel>
@@ -31,7 +31,7 @@ public partial class TaskRowView : UserControl
// OnTunnelPointerPressed (TasksIslandView) only selects on the left button, so
// right-click needs its own explicit selection before the menu opens.
vm.SelectedTask = row;
vm.SelectFrom(row, "context-menu");
var menu = new ContextMenu { DataContext = row };
@@ -21,7 +21,7 @@
Foreground="{DynamicResource TextMuteBrush}"
Text="{Binding Subtitle}"
TextTrimming="CharacterEllipsis"/>
<ctl:OperationIndicator DataContext="{Binding PlanningOperation}"/>
<ctl:OperationIndicator Status="{Binding PlanningOperation}"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
@@ -131,7 +131,7 @@ public partial class TasksIslandView : UserControl
if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return;
// Select now so the details pane updates whether the gesture becomes a click or a drag.
if (DataContext is TasksIslandViewModel vm) vm.SelectedTask = row;
if (DataContext is TasksIslandViewModel vm) vm.SelectFrom(row, "pointer-press");
// If the click landed on a nested Button (e.g. the done-toggle checkbox or star),
// don't start a drag — that would capture the pointer and swallow the inner Click.
+2 -2
View File
@@ -18,7 +18,7 @@ internal static class JumpToTaskHelper
s.Lists.SelectedList = item;
var existing = s.Tasks.Items.FirstOrDefault(r => r.Id == taskId);
if (existing is not null) { s.Tasks.SelectedTask = existing; return; }
if (existing is not null) { s.Tasks.SelectFrom(existing, "jump-to-task"); return; }
var tcs = new TaskCompletionSource<bool>();
void OnChanged(object? _, NotifyCollectionChangedEventArgs __)
@@ -32,7 +32,7 @@ internal static class JumpToTaskHelper
{
await Task.WhenAny(tcs.Task, Task.Delay(5000));
var row = s.Tasks.Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) s.Tasks.SelectedTask = row;
if (row is not null) s.Tasks.SelectFrom(row, "jump-to-task");
}
finally
{
+1 -1
View File
@@ -83,7 +83,7 @@
<MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/>
</MenuItem>
</Menu>
<controls:OperationIndicator DataContext="{Binding UpdateCheck.Op}" Margin="8,0,0,0"/>
<controls:OperationIndicator Status="{Binding UpdateCheck.Op}" Margin="8,0,0,0"/>
</StackPanel>
<!-- Middle: draggable strip -->
@@ -39,7 +39,7 @@
Command="{Binding ForgetFoldersCommand}"
IsVisible="{Binding HasFolders}"/>
</Grid>
<ctl:OperationIndicator DataContext="{Binding ScanOp}"/>
<ctl:OperationIndicator Status="{Binding ScanOp}"/>
</StackPanel>
<!-- Repo checklist -->
@@ -281,7 +281,7 @@
<Button Classes="btn" Content="{loc:Tr settings.files.restoreDefaultAgents}"
Command="{Binding Files.RestoreDefaultAgentsCommand}"
HorizontalAlignment="Left"/>
<ctl:OperationIndicator DataContext="{Binding Files.RestoreOp}"/>
<ctl:OperationIndicator Status="{Binding Files.RestoreOp}"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="{loc:Tr settings.files.promptsSection}"/>
@@ -504,14 +504,14 @@
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr settings.skills.installButton}"
Command="{Binding SessionSkills.InstallCommand}"/>
</Grid>
<ctl:OperationIndicator DataContext="{Binding SessionSkills.InstallOp}"/>
<ctl:OperationIndicator Status="{Binding SessionSkills.InstallOp}"/>
</StackPanel>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="{loc:Tr settings.skills.installedSection}"/>
<ctl:OperationIndicator DataContext="{Binding SessionSkills.UpdateOp}"/>
<ctl:OperationIndicator Status="{Binding SessionSkills.UpdateOp}"/>
<ItemsControl ItemsSource="{Binding SessionSkills.Skills}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="services:SessionSkillDto">
@@ -26,7 +26,7 @@
IsVisible="{Binding EmptyStateVisible}"/>
<Button Classes="btn" Content="{loc:Tr modals.weeklyReport.regenerate}" Command="{Binding GenerateCommand}"
IsVisible="{Binding HasReport}"/>
<ctl:OperationIndicator DataContext="{Binding GenerateOperation}"/>
<ctl:OperationIndicator Status="{Binding GenerateOperation}"/>
</StackPanel>
<TextBlock DockPanel.Dock="Top" Classes="meta" Margin="0,8,0,0"
@@ -179,6 +179,30 @@ public class OperationStatusTests
Assert.False(status.ShowIndicator);
}
[Fact]
public void Ticks_that_drain_after_the_operation_ended_do_not_resurrect_the_indicator()
{
// The frozen-spinner bug, measured in the running app: the UI thread is blocked for ~2.4s
// while the main window is built. The tick timer fires on a threadpool thread meanwhile and
// only *posts* its UI work, so three ticks queued up, End ran first — and the queued ticks
// then set ShowIndicator back to true. The timer was already disposed, so nothing ever
// cleared it again. A poster that queues instead of running stands in for that blocked
// dispatcher.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var queued = new List<Action>();
var status = new OperationStatus(time, queued.Add);
var op = status.Begin("Checking for updates…");
time.Advance(TimeSpan.FromSeconds(3)); // ticks fire, their UI work piles up unprocessed
op.Dispose(); // the operation finishes before the queue drains
Assert.False(status.ShowIndicator);
foreach (var action in queued.ToArray()) action(); // dispatcher catches up
Assert.False(status.ShowIndicator);
Assert.False(status.IsRunning);
}
[Fact]
public void Report_overwrites_the_label()
{
@@ -35,6 +35,60 @@ public class OperationTimingTests
Assert.Equal("TasksIsland.LoadForList", root2.GetProperty("op").GetString());
Assert.Equal(7, root2.GetProperty("ms").GetInt64());
Assert.False(root2.GetProperty("ok").GetBoolean());
// Runs of the app interleave in one file — without the pid a restart is
// indistinguishable from concurrent work inside one instance.
Assert.Equal(Environment.ProcessId, root1.GetProperty("pid").GetInt32());
}
finally
{
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void Record_DropsFastSuccessesButKeepsEveryFailure()
{
var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N"));
var path = Path.Combine(dir, "operation-timing.ndjson");
var sink = new OperationTiming(path, minMs: 25);
try
{
sink.Record("db", "Fast", TimeSpan.FromMilliseconds(3), ok: true); // noise
sink.Record("db", "Cancelled", TimeSpan.FromMilliseconds(1), ok: false); // churn signal
sink.Record("db", "Slow", TimeSpan.FromMilliseconds(25), ok: true); // boundary: kept
var ops = File.ReadAllLines(path)
.Select(l => JsonDocument.Parse(l).RootElement.GetProperty("op").GetString())
.ToArray();
Assert.Equal(new[] { "Cancelled", "Slow" }, ops);
}
finally
{
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void Record_RollsTheFileOverOnceItPassesTheSizeCap()
{
var dir = Path.Combine(Path.GetTempPath(), "claudedo_optiming_" + Guid.NewGuid().ToString("N"));
var path = Path.Combine(dir, "operation-timing.ndjson");
var sink = new OperationTiming(path, maxBytes: 200);
try
{
for (var i = 0; i < 12; i++)
sink.Record("db", "Op" + i, TimeSpan.FromMilliseconds(100), ok: true);
Assert.True(File.Exists(path + ".1"), "expected one rolled-over file");
Assert.True(new FileInfo(path).Length < 200 + 200, "live file should have been truncated by the roll");
// The newest line survives the roll — rotation must never eat the current write.
var last = File.ReadAllLines(path)[^1];
Assert.Equal("Op11", JsonDocument.Parse(last).RootElement.GetProperty("op").GetString());
}
finally
{