From abf089b05e12d471eaa96c33861c958a27b316c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 14:37:40 +0000 Subject: [PATCH 1/4] refactor(monitors): replace polling loops with ktsu.IntervalAction Replaces hand-rolled `while (!ct.IsCancellationRequested) { ...; await Task.Delay(refreshInterval, ct); }` loops in MemFragService, MachineMonitorService, and BuildMonitorService with `ktsu.IntervalAction`, which provides the same fixed-interval polling semantics out of the box and prevents overlapping executions when a tick takes longer than the interval. Fixes #12. --- Directory.Packages.props | 1 + KtsuTools.BuildMonitor/BuildMonitorService.cs | 34 ++++++++++----- .../KtsuTools.BuildMonitor.csproj | 1 + KtsuTools.Machine/KtsuTools.Machine.csproj | 1 + KtsuTools.Machine/MachineMonitorService.cs | 41 ++++++++++--------- KtsuTools.MemFrag/KtsuTools.MemFrag.csproj | 1 + KtsuTools.MemFrag/MemFragService.cs | 40 ++++++++++++------ 7 files changed, 76 insertions(+), 43 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0096f53..86e164f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -37,6 +37,7 @@ + diff --git a/KtsuTools.BuildMonitor/BuildMonitorService.cs b/KtsuTools.BuildMonitor/BuildMonitorService.cs index a584655..7676029 100644 --- a/KtsuTools.BuildMonitor/BuildMonitorService.cs +++ b/KtsuTools.BuildMonitor/BuildMonitorService.cs @@ -10,6 +10,7 @@ namespace KtsuTools.BuildMonitor; using System.Threading; using System.Threading.Tasks; using Humanizer; +using ktsu.IntervalAction; using KtsuTools.Core.Services.GitHub; using Spectre.Console; using Spectre.Console.Rendering; @@ -37,31 +38,42 @@ await AnsiConsole.Live(initial) .AutoClear(true) .StartAsync(async ctx => { - while (!ct.IsCancellationRequested) + TimeSpan interval = TimeSpan.FromSeconds(refreshIntervalSeconds); + + void Tick() { try { - Table table = await BuildDashboardTableAsync(owner, ct).ConfigureAwait(false); + Table table = BuildDashboardTableAsync(owner, ct).GetAwaiter().GetResult(); ctx.UpdateTarget(table); } catch (OperationCanceledException) { - break; } catch (HttpRequestException ex) { ctx.UpdateTarget(new Markup($"[red]API Error: {ex.Message.EscapeMarkup()}[/]")); } + } - try - { - await Task.Delay(TimeSpan.FromSeconds(refreshIntervalSeconds), ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } + Tick(); + + using IntervalAction ticker = IntervalAction.Start(new IntervalActionOptions + { + ActionInterval = interval, + PollingInterval = interval, + Action = Tick, + }); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false); } + catch (OperationCanceledException) + { + } + + ticker.Stop(); }).ConfigureAwait(false); } diff --git a/KtsuTools.BuildMonitor/KtsuTools.BuildMonitor.csproj b/KtsuTools.BuildMonitor/KtsuTools.BuildMonitor.csproj index a0a01df..be68e45 100644 --- a/KtsuTools.BuildMonitor/KtsuTools.BuildMonitor.csproj +++ b/KtsuTools.BuildMonitor/KtsuTools.BuildMonitor.csproj @@ -11,6 +11,7 @@ + diff --git a/KtsuTools.Machine/KtsuTools.Machine.csproj b/KtsuTools.Machine/KtsuTools.Machine.csproj index be933ac..7825c24 100644 --- a/KtsuTools.Machine/KtsuTools.Machine.csproj +++ b/KtsuTools.Machine/KtsuTools.Machine.csproj @@ -11,6 +11,7 @@ + diff --git a/KtsuTools.Machine/MachineMonitorService.cs b/KtsuTools.Machine/MachineMonitorService.cs index 21028bd..5cd5c4a 100644 --- a/KtsuTools.Machine/MachineMonitorService.cs +++ b/KtsuTools.Machine/MachineMonitorService.cs @@ -10,6 +10,7 @@ namespace KtsuTools.Machine; using System.Linq; using System.Threading; using System.Threading.Tasks; +using ktsu.IntervalAction; using LibreHardwareMonitor.Hardware; using Spectre.Console; using Spectre.Console.Rendering; @@ -48,31 +49,33 @@ public async Task RunDashboardAsync(int refreshIntervalMs = 1000, Cancellat AnsiConsole.WriteLine(); IRenderable initial = new Text("Loading hardware sensors..."); + Computer computerCapture = computer; await AnsiConsole.Live(initial) .AutoClear(true) .StartAsync(async ctx => { - while (!ct.IsCancellationRequested) + TimeSpan interval = TimeSpan.FromMilliseconds(refreshIntervalMs); + + void Tick() => ctx.UpdateTarget(BuildDashboard(computerCapture)); + + Tick(); + + using IntervalAction ticker = IntervalAction.Start(new IntervalActionOptions + { + ActionInterval = interval, + PollingInterval = interval, + Action = Tick, + }); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { - try - { - Table dashboard = BuildDashboard(computer); - ctx.UpdateTarget(dashboard); - } - catch (OperationCanceledException) - { - break; - } - - try - { - await Task.Delay(refreshIntervalMs, ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } } + + ticker.Stop(); }).ConfigureAwait(false); } finally diff --git a/KtsuTools.MemFrag/KtsuTools.MemFrag.csproj b/KtsuTools.MemFrag/KtsuTools.MemFrag.csproj index 4d92c11..9a5e030 100644 --- a/KtsuTools.MemFrag/KtsuTools.MemFrag.csproj +++ b/KtsuTools.MemFrag/KtsuTools.MemFrag.csproj @@ -9,6 +9,7 @@ + diff --git a/KtsuTools.MemFrag/MemFragService.cs b/KtsuTools.MemFrag/MemFragService.cs index 9e4631f..d298e81 100644 --- a/KtsuTools.MemFrag/MemFragService.cs +++ b/KtsuTools.MemFrag/MemFragService.cs @@ -9,6 +9,7 @@ namespace KtsuTools.MemFrag; using System.Globalization; using System.Threading; using System.Threading.Tasks; +using ktsu.IntervalAction; using Spectre.Console; using Spectre.Console.Rendering; @@ -78,33 +79,46 @@ public async Task MonitorAsync(int processId, int refreshIntervalMs = 1000, AnsiConsole.WriteLine(); IRenderable initial = new Text("Loading..."); + Process processCapture = process; await AnsiConsole.Live(initial) .AutoClear(true) .StartAsync(async ctx => { - while (!ct.IsCancellationRequested) + using CancellationTokenSource exitedCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + TimeSpan interval = TimeSpan.FromMilliseconds(refreshIntervalMs); + + void Tick() { try { - process.Refresh(); - Table table = BuildMemoryTable(process); - ctx.UpdateTarget(table); + processCapture.Refresh(); + ctx.UpdateTarget(BuildMemoryTable(processCapture)); } catch (InvalidOperationException) { ctx.UpdateTarget(new Markup("[red]Process has exited.[/]")); - break; + exitedCts.Cancel(); } + } - try - { - await Task.Delay(refreshIntervalMs, ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - break; - } + Tick(); + + using IntervalAction ticker = IntervalAction.Start(new IntervalActionOptions + { + ActionInterval = interval, + PollingInterval = interval, + Action = Tick, + }); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, exitedCts.Token).ConfigureAwait(false); } + catch (OperationCanceledException) + { + } + + ticker.Stop(); }).ConfigureAwait(false); } From a2c0bbf8a85b1dcfb01fa359c87844e2d8e94b7b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 14:44:40 +0000 Subject: [PATCH 2/4] fix(monitors): drop using on IntervalAction (not IDisposable) ktsu.IntervalAction.IntervalAction exposes Stop() but does not implement IDisposable, so the using-declaration form fails to compile. Stop() at end of scope is sufficient to halt the polling thread. --- KtsuTools.BuildMonitor/BuildMonitorService.cs | 2 +- KtsuTools.Machine/MachineMonitorService.cs | 2 +- KtsuTools.MemFrag/MemFragService.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/KtsuTools.BuildMonitor/BuildMonitorService.cs b/KtsuTools.BuildMonitor/BuildMonitorService.cs index 7676029..5d97f7a 100644 --- a/KtsuTools.BuildMonitor/BuildMonitorService.cs +++ b/KtsuTools.BuildMonitor/BuildMonitorService.cs @@ -58,7 +58,7 @@ void Tick() Tick(); - using IntervalAction ticker = IntervalAction.Start(new IntervalActionOptions + IntervalAction ticker = IntervalAction.Start(new IntervalActionOptions { ActionInterval = interval, PollingInterval = interval, diff --git a/KtsuTools.Machine/MachineMonitorService.cs b/KtsuTools.Machine/MachineMonitorService.cs index 5cd5c4a..9c29245 100644 --- a/KtsuTools.Machine/MachineMonitorService.cs +++ b/KtsuTools.Machine/MachineMonitorService.cs @@ -60,7 +60,7 @@ await AnsiConsole.Live(initial) Tick(); - using IntervalAction ticker = IntervalAction.Start(new IntervalActionOptions + IntervalAction ticker = IntervalAction.Start(new IntervalActionOptions { ActionInterval = interval, PollingInterval = interval, diff --git a/KtsuTools.MemFrag/MemFragService.cs b/KtsuTools.MemFrag/MemFragService.cs index d298e81..06e9e68 100644 --- a/KtsuTools.MemFrag/MemFragService.cs +++ b/KtsuTools.MemFrag/MemFragService.cs @@ -103,7 +103,7 @@ void Tick() Tick(); - using IntervalAction ticker = IntervalAction.Start(new IntervalActionOptions + IntervalAction ticker = IntervalAction.Start(new IntervalActionOptions { ActionInterval = interval, PollingInterval = interval, From 598236ce92d95d6cbb6ac5df724a82180d9cdf69 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 14:53:07 +0000 Subject: [PATCH 3/4] fix: add missing CA1062 null-checks to strong-path service entry points The string-to-strong-path migration in MergeService, FileExplorerService, and CodeGenService dropped the Ensure.NotNull check on the path parameter. The new build pipeline treats CA1062 as an error, so the analyzer flags every public method that dereferences a non-nullable reference parameter without validating it. Re-add the checks that the migration deleted. --- KtsuTools.CodeGen/CodeGenService.cs | 1 + KtsuTools.FileExplorer/FileExplorerService.cs | 2 ++ KtsuTools.Merge/MergeService.cs | 1 + 3 files changed, 4 insertions(+) diff --git a/KtsuTools.CodeGen/CodeGenService.cs b/KtsuTools.CodeGen/CodeGenService.cs index 6dd3f61..1f3ec66 100644 --- a/KtsuTools.CodeGen/CodeGenService.cs +++ b/KtsuTools.CodeGen/CodeGenService.cs @@ -285,6 +285,7 @@ public class CodeGenService public async Task GenerateAsync(AbsoluteFilePath inputFile, string language, AbsoluteFilePath? outputFile = null, CancellationToken ct = default) #pragma warning restore CA1822 { + Ensure.NotNull(inputFile); Ensure.NotNull(language); string fullPath = inputFile.ToString(); diff --git a/KtsuTools.FileExplorer/FileExplorerService.cs b/KtsuTools.FileExplorer/FileExplorerService.cs index d748529..0b0e950 100644 --- a/KtsuTools.FileExplorer/FileExplorerService.cs +++ b/KtsuTools.FileExplorer/FileExplorerService.cs @@ -62,6 +62,8 @@ public class FileExplorerService public async Task RunAsync(AbsoluteDirectoryPath startPath, bool showHidden = false, bool showSizes = true, CancellationToken ct = default) #pragma warning restore CA1822 { + Ensure.NotNull(startPath); + string currentPath = startPath.ToString(); if (!Directory.Exists(currentPath)) diff --git a/KtsuTools.Merge/MergeService.cs b/KtsuTools.Merge/MergeService.cs index e549908..2503e84 100644 --- a/KtsuTools.Merge/MergeService.cs +++ b/KtsuTools.Merge/MergeService.cs @@ -57,6 +57,7 @@ public class MergeService public async Task RunMergeAsync(AbsoluteDirectoryPath directory, string filename, CancellationToken ct = default) #pragma warning restore CA1822 { + Ensure.NotNull(directory); Ensure.NotNull(filename); string fullPath = directory.ToString(); From ed8062fad3fbdf8874614572ee6689a83bf79a58 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 14:58:07 +0000 Subject: [PATCH 4/4] fix(sync): suppress CA1819 on Filename setting Spectre.Console.Cli only binds multi-value command options to T[]; List/IReadOnlyList won't bind. Suppressing the analyzer here is the only viable option. --- KtsuTools/Commands/SyncCommand.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/KtsuTools/Commands/SyncCommand.cs b/KtsuTools/Commands/SyncCommand.cs index 5ebfb3d..bbd5abe 100644 --- a/KtsuTools/Commands/SyncCommand.cs +++ b/KtsuTools/Commands/SyncCommand.cs @@ -38,7 +38,9 @@ public sealed class Settings : CommandSettings /// [CommandOption("--filename ")] [Description("Filename pattern to scan for. Repeat the flag or pass a comma-separated list to sync several files in one run.")] +#pragma warning disable CA1819 // Properties should not return arrays - Spectre.Console.Cli binds multi-value options via T[] only. public string[] Filename { get; init; } = []; +#pragma warning restore CA1819 /// /// Gets a value indicating whether to push without prompting when all unpushed commits were authored by KtsuTools.