From 0a5eec7ea3e4afb74146be37d39eeb344cfa0f10 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:28:26 +0000 Subject: [PATCH] Reject --for durations longer than a wait can take DurationParser accepted anything up to TimeSpan.MaxValue, but the console runner's ManualResetEventSlim.Wait and the tray's timer both throw past int.MaxValue milliseconds (about 24.8 days), so `--for 25d` started the tool and then crashed it with an unhandled exception. Cap TryParse at DurationParser.MaxDuration, and have the command line report a too-long value as a usage error rather than as "not a duration". Fixes #14 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CGpRxGxuvhstHo5zXQoWSA --- TrayApp.Test/CommandLineParserTests.cs | 7 ++++++ TrayApp.Test/DurationParserTests.cs | 27 +++++++++++++++++++++ TrayApp/Cli/CommandLineParser.cs | 8 ++++++- TrayApp/Cli/DurationParser.cs | 33 +++++++++++++++++++++++++- 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/TrayApp.Test/CommandLineParserTests.cs b/TrayApp.Test/CommandLineParserTests.cs index c16d952..6a1fe5a 100644 --- a/TrayApp.Test/CommandLineParserTests.cs +++ b/TrayApp.Test/CommandLineParserTests.cs @@ -67,6 +67,13 @@ public void TryParse_WithAnUnreadableDuration_Fails() Assert.Contains("is not a duration", error, StringComparison.Ordinal); } + [TestMethod] + public void TryParse_WithADurationLongerThanATimerCanWait_FailsWithAUsageError() + { + Assert.IsFalse(CommandLineParser.TryParse(["--for", "25d"], NoOptions, out _, out string error)); + Assert.Contains("--for can be at most 24 days", error, StringComparison.Ordinal); + } + [TestMethod] public void TryParse_WithADurationAndNoValue_Fails() { diff --git a/TrayApp.Test/DurationParserTests.cs b/TrayApp.Test/DurationParserTests.cs index f955f89..f584e9a 100644 --- a/TrayApp.Test/DurationParserTests.cs +++ b/TrayApp.Test/DurationParserTests.cs @@ -69,4 +69,31 @@ public void TryParse_WithAnInvalidDuration_ReturnsFalse(string? text) Assert.IsFalse(DurationParser.TryParse(text, out TimeSpan duration)); Assert.AreEqual(TimeSpan.Zero, duration); } + + [TestMethod] + public void TryParse_AtTheLongestWaitATimerTakes_ReturnsTheDuration() + { + Assert.IsTrue(DurationParser.TryParse("24d", out TimeSpan duration)); + Assert.AreEqual(TimeSpan.FromDays(24), duration); + Assert.IsTrue(duration <= DurationParser.MaxDuration); + } + + [TestMethod] + [DataRow("25d")] + [DataRow("24d21h")] + public void TryParse_LongerThanATimerCanWait_ReturnsFalse(string text) + { + // ManualResetEventSlim.Wait and the tray's timer both throw past int.MaxValue milliseconds, about + // 24.8 days, so a longer duration would start the tool and then crash it. + Assert.IsFalse(DurationParser.TryParse(text, out TimeSpan duration)); + Assert.AreEqual(TimeSpan.Zero, duration); + } + + [TestMethod] + public void MaxDuration_IsTheLongestTimeoutAWaitTakes() + { + using System.Threading.ManualResetEventSlim signal = new(initialState: true); + Assert.IsTrue(signal.Wait(DurationParser.MaxDuration)); + _ = Assert.ThrowsExactly(() => signal.Wait(DurationParser.MaxDuration + TimeSpan.FromMilliseconds(1))); + } } diff --git a/TrayApp/Cli/CommandLineParser.cs b/TrayApp/Cli/CommandLineParser.cs index 2ff5b4c..42526b7 100644 --- a/TrayApp/Cli/CommandLineParser.cs +++ b/TrayApp/Cli/CommandLineParser.cs @@ -127,12 +127,18 @@ private static ReadResult ReadDuration( return ReadResult.Failed; } - if (!DurationParser.TryParse(text, out TimeSpan duration)) + if (!DurationParser.TryParseAnyLength(text, out TimeSpan duration)) { error = $"'{text}' is not a duration. Try 45s, 90m, 2h, or 1h30m."; return ReadResult.Failed; } + if (duration > DurationParser.MaxDuration) + { + error = $"'{text}' is too long: {argument} can be at most 24 days."; + return ReadResult.Failed; + } + state.Duration = duration; return ReadResult.Read; } diff --git a/TrayApp/Cli/DurationParser.cs b/TrayApp/Cli/DurationParser.cs index 14aa8b2..531bf9b 100644 --- a/TrayApp/Cli/DurationParser.cs +++ b/TrayApp/Cli/DurationParser.cs @@ -16,13 +16,44 @@ namespace ktsu.TrayApp.Cli; /// public static class DurationParser { + /// + /// The longest duration accepts: + /// milliseconds, a little under 24 days and 21 hours. + /// + /// + /// That is the longest timeout a wait handle or a timer takes. Anything longer parses cleanly and then + /// throws from the wait that enforces it, after the tool has + /// already started. + /// + public static readonly TimeSpan MaxDuration = TimeSpan.FromMilliseconds(int.MaxValue); + /// /// Parses a duration such as 45s, 90m, 2h, 1h30m, or a bare 90 for minutes. /// /// The text to read. /// Receives the duration when parsing succeeds. - /// when is a positive duration. + /// + /// when is a positive duration no longer than . + /// public static bool TryParse(string? text, out TimeSpan duration) + { + if (!TryParseAnyLength(text, out duration) || duration > MaxDuration) + { + duration = TimeSpan.Zero; + return false; + } + + return true; + } + + /// + /// Parses a duration as does, without the upper bound, so a + /// caller can tell a value that is too long apart from one that is not a duration at all. + /// + /// The text to read. + /// Receives the duration when parsing succeeds. + /// when is a positive duration. + internal static bool TryParseAnyLength(string? text, out TimeSpan duration) { duration = TimeSpan.Zero;