From 924b96136d72f1ca898c38d3226358b7a0d9abfa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 15:27:32 +0000 Subject: [PATCH] Treat an out-of-range navigation timeout as no timeout instead of throwing after undo/redo [patch] NavigateSafelyAsync called CancelAfter outside its try, so TimeSpan.MaxValue or a negative timeout threw ArgumentOutOfRangeException after the undo or redo had already been applied. The token source setup now sits inside the try, and timeouts that are not positive or exceed what CancelAfter accepts on every target run navigation without a timeout. Fixes ktsu-dev/UndoRedo#104 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013xQ2dceFuWS8VLEn2xLuZE --- UndoRedo.Test/UndoRedoStackTests.cs | 29 ++++++++++++++++++++++++++++ UndoRedo/Services/UndoRedoService.cs | 18 ++++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/UndoRedo.Test/UndoRedoStackTests.cs b/UndoRedo.Test/UndoRedoStackTests.cs index e70b1bc..efbe7be 100644 --- a/UndoRedo.Test/UndoRedoStackTests.cs +++ b/UndoRedo.Test/UndoRedoStackTests.cs @@ -1109,6 +1109,35 @@ public override ICommand MergeWith(ICommand other) } } + public static IEnumerable NavigationTimeoutsOutsideCancelAfterRange => + [ + [TimeSpan.MaxValue], + [TimeSpan.FromDays(60)], + [TimeSpan.FromSeconds(-5)], + [TimeSpan.MinValue], + ]; + + [TestMethod] + [DynamicData(nameof(NavigationTimeoutsOutsideCancelAfterRange))] + public async Task UndoRedoAsync_NavigationTimeoutOutsideCancelAfterRange_AppliesAndNavigatesWithoutThrowing(TimeSpan timeout) + { + // Arrange + UndoRedoService stack = new(new StackManager(), new SaveBoundaryManager(), new CommandMerger(), + new UndoRedoOptions(DefaultNavigationTimeout: timeout)); + MockNavigationProvider navigationProvider = new(); + stack.SetNavigationProvider(navigationProvider); + int value = 0; + stack.Execute(new DelegateCommand("Increment", () => value++, () => value--, navigationContext: "editor")); + + // Act & Assert: CancelAfter rejects these values, which surfaced after the change was applied + Assert.IsTrue(await stack.UndoAsync().ConfigureAwait(false)); + Assert.AreEqual(0, value); + Assert.AreEqual("editor", navigationProvider.LastNavigatedContext, "Navigation should run with no timeout"); + + Assert.IsTrue(await stack.RedoAsync().ConfigureAwait(false)); + Assert.AreEqual(1, value); + } + private sealed class ThrowingNavigationProvider : INavigationProvider { public Task NavigateToAsync(string context, CancellationToken cancellationToken = default) => diff --git a/UndoRedo/Services/UndoRedoService.cs b/UndoRedo/Services/UndoRedoService.cs index 152b1c2..8a442ba 100644 --- a/UndoRedo/Services/UndoRedoService.cs +++ b/UndoRedo/Services/UndoRedoService.cs @@ -254,6 +254,11 @@ public async Task UndoToSaveBoundaryAsync(SaveBoundary saveBoundary, bool return true; } + /// + /// The longest navigation timeout accepts on every target framework. + /// + private static readonly TimeSpan MaxNavigationTimeout = TimeSpan.FromMilliseconds(int.MaxValue); + /// /// Navigates to where a change was made, after the undo or redo has already been applied. /// Navigation is best effort: any failure is swallowed, because an exception here would tell the @@ -266,11 +271,18 @@ private async Task NavigateSafelyAsync(string? navigationContext, CancellationTo return; } - using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(_options.EffectiveNavigationTimeout); - try { + using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // CancelAfter throws for negative delays and for delays past its limit, which is + // int.MaxValue ms on older targets. Treat those as "no timeout" rather than failing here. + TimeSpan timeout = _options.EffectiveNavigationTimeout; + if (timeout > TimeSpan.Zero && timeout <= MaxNavigationTimeout) + { + cts.CancelAfter(timeout); + } + await _navigationProvider.NavigateToAsync(navigationContext!, cts.Token).ConfigureAwait(false); } #pragma warning disable CA1031 // Do not catch general exception types